From e311cad7acc7e47bc709c734cd84c845b9805832 Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 24 Apr 2025 05:17:58 -0600 Subject: [PATCH] Partial on #2975 - Replaces Menu v1 in many places with v2 (#4040) * touching publish.yml * Fixed UICatalog bugs. Added fluent tests. * marked v1 menu stuff as obsolte * Tweaks. Added View.GetSubMenus(). * fixed unit tests * general messing around * general messing around * Playing with Fluent * ColorScheme tweaks * WIP: ColorScheme tweaks * Playing with Fluent * Merged from laptop2 * Hacky-ish fixes to: - #4016 - #4014 * Fixed Region bug preventing menus without borders from working * Tweaks * Fixed a bunch of CM issues * Fixed OoptionSelector * ip * FixedCM issues * Fixed CM issues2 * Revert "FixedCM issues" This reverts commit dd6c6a70a3d16a6a13b572b80f9a41c8a721ed1c. * Reverted stuff * Found and fixed bug in AllViews_Center_Properly * Fixed CM issues2 * removed menuv2 onapplied. Changed how UICatalog Applys CM * changed test time out to see if it helkps with ubuntu fails * reset app on fail? * back to 1500ms * Made StatusBar nullable. * Code Cleanup. * HexEditor Code Cleanup. * HexEditor Code Cleanup. * Back to 3000ms. Sigh. * Trying different logic * Trying different logic2 * Fixed potential crash in runlop * Fixed potential crash in runlop2 * Tweaked Spinner stuff * Removed TabView from TextEffects scenario. Not needed and possible culprit. * back to 2000ms * WIP: Revamping menu scenarios * Menu Scenario refinements. Fixed a few bugs. Code cleanup. * fixed unit test * Fixed warnings * Fixed warnings2 * Fixed File.Exit * WIP: Dealing with QuitKey struggles * WIP: Dealing with QuitKey struggles 2 * WIP: Dealing with QuitKey struggles 3 * Fixed ListView collection nav bug * Fixed a bunch of menu stuff. Fixed Appv2 stuff. * Lots of refactoring and fixing * Lots of unit test issues * Fixed DebugIDisposable issues * Fixed release build issue * Fixed release build issue 2 * DebugIDisposable -> EnableDebugIDisposableAsserts and more * DebugIDisposable -> EnableDebugIDisposableAsserts and more 2 * Fixed Menus scenario - context menu * Added @bdisp suggested assert. Commented it out as it breaks tests. * Code cleanup * Fixed disposed but * Fixed UICatalog exit * Fixed Unit test I broke. Added 'Minimal' Theme that turns off all borders etc... --- .../Application/Application.Keyboard.cs | 11 + Terminal.Gui/Application/Application.Mouse.cs | 13 +- Terminal.Gui/Application/Application.Run.cs | 32 +- Terminal.Gui/Application/Application.cs | 14 +- Terminal.Gui/Application/ApplicationImpl.cs | 39 +- .../Application/ApplicationPopover.cs | 3 + Terminal.Gui/Application/PopoverBaseImpl.cs | 21 +- Terminal.Gui/Application/RunState.cs | 39 +- .../Configuration/AttributeJsonConverter.cs | 2 +- .../Configuration/ConfigurationManager.cs | 1 + .../Configuration/ScopeJsonConverter.cs | 4 +- .../ConsoleDrivers/NetDriver/NetEvents.cs | 3 +- .../ConsoleDrivers/V2/ApplicationV2.cs | 32 +- Terminal.Gui/Drawing/Attribute.cs | 5 + Terminal.Gui/Drawing/Color/ColorScheme.cs | 3 + Terminal.Gui/Drawing/Region.cs | 120 ++-- Terminal.Gui/Resources/config.json | 23 +- Terminal.Gui/Terminal.Gui.sln | 24 + Terminal.Gui/View/Adornment/Border.cs | 1 + Terminal.Gui/View/Adornment/Margin.cs | 95 ++- Terminal.Gui/View/Adornment/ShadowView.cs | 1 + Terminal.Gui/View/IDesignable.cs | 2 +- Terminal.Gui/View/View.Adornments.cs | 45 +- Terminal.Gui/View/View.Attribute.cs | 114 +--- Terminal.Gui/View/View.ColorScheme.cs | 211 +++++++ Terminal.Gui/View/View.Command.cs | 24 +- Terminal.Gui/View/View.Drawing.cs | 55 +- Terminal.Gui/View/View.Hierarchy.cs | 42 +- Terminal.Gui/View/View.Keyboard.cs | 15 + Terminal.Gui/View/View.Mouse.cs | 60 +- Terminal.Gui/View/View.Navigation.cs | 17 +- Terminal.Gui/View/View.Text.cs | 6 - Terminal.Gui/View/View.cs | 121 ++-- Terminal.Gui/Views/Bar.cs | 24 +- Terminal.Gui/Views/CheckBox.cs | 31 +- Terminal.Gui/Views/Dialog.cs | 4 +- Terminal.Gui/Views/FlagSelector.cs | 261 ++++++-- Terminal.Gui/Views/FlagSelectorTEnum.cs | 99 +++ Terminal.Gui/Views/ListView.cs | 23 +- Terminal.Gui/Views/Menu/MenuBarItemv2.cs | 83 ++- Terminal.Gui/Views/Menu/MenuBarv2.cs | 572 ++++++++++++++---- Terminal.Gui/Views/Menu/MenuItemv2.cs | 61 +- Terminal.Gui/Views/Menu/Menuv2.cs | 95 ++- Terminal.Gui/Views/Menu/PopoverMenu.cs | 220 ++++--- Terminal.Gui/Views/Menuv1/Menu.cs | 2 + Terminal.Gui/Views/Menuv1/MenuBar.cs | 3 +- Terminal.Gui/Views/Menuv1/MenuBarItem.cs | 1 + .../Views/Menuv1/MenuClosingEventArgs.cs | 2 + Terminal.Gui/Views/Menuv1/MenuItem.cs | 2 + .../Views/Menuv1/MenuOpenedEventArgs.cs | 1 + .../Views/Menuv1/MenuOpeningEventArgs.cs | 2 + Terminal.Gui/Views/OptionSelector.cs | 318 ++++++++++ Terminal.Gui/Views/RadioGroup.cs | 21 +- Terminal.Gui/Views/SelectedItemChangedArgs.cs | 13 +- Terminal.Gui/Views/Shortcut.cs | 202 +++---- Terminal.Gui/Views/StatusBar.cs | 67 +- Terminal.Gui/Views/Wizard/WizardStep.cs | 6 +- TerminalGuiFluentTesting/FakeInput.cs | 4 +- TerminalGuiFluentTesting/GuiTestContext.cs | 216 ++++--- .../FluentTests/BasicFluentAssertionTests.cs | 46 +- .../FluentTests/FileDialogFluentTests.cs | 71 +-- .../FluentTests/MenuBarv2Tests.cs | 509 ++++++++++++++++ .../FluentTests/PopverMenuTests.cs | 243 ++++++++ .../UICatalog/ScenarioTests.cs | 58 +- Tests/StressTests/ScenariosStressTests.cs | 2 +- .../Application/ApplicationPopoverTests.cs | 23 +- .../UnitTests/Application/ApplicationTests.cs | 82 ++- Tests/UnitTests/Application/KeyboardTests.cs | 93 --- Tests/UnitTests/Application/RunStateTests.cs | 2 +- Tests/UnitTests/AutoInitShutdownAttribute.cs | 2 +- .../Configuration/SettingsScopeTests.cs | 2 +- .../ConsoleDrivers/V2/ApplicationV2Tests.cs | 260 ++++++-- Tests/UnitTests/Dialogs/DialogTests.cs | 3 - Tests/UnitTests/Dialogs/MessageBoxTests.cs | 2 +- .../TestRespondersDisposedAttribute.cs | 5 +- Tests/UnitTests/TestsAllViews.cs | 24 +- Tests/UnitTests/Text/TextFormatterTests.cs | 2 +- .../View/Keyboard/KeyBindingsTests.cs | 45 +- Tests/UnitTests/View/Layout/Dim.Tests.cs | 31 - Tests/UnitTests/View/ViewTests.cs | 7 - Tests/UnitTests/Views/AllViewsTests.cs | 3 +- Tests/UnitTests/Views/CheckBoxTests.cs | 2 + Tests/UnitTests/Views/ColorPickerTests.cs | 28 +- Tests/UnitTests/Views/ListViewTests.cs | 30 + Tests/UnitTests/Views/MenuBarTests.cs | 148 +++-- .../UnitTests/Views/Menuv1/MenuBarv1Tests.cs | 2 + Tests/UnitTests/Views/Menuv1/Menuv1Tests.cs | 1 + Tests/UnitTests/Views/RadioGroupTests.cs | 4 +- Tests/UnitTests/Views/ShortcutTests.cs | 2 +- Tests/UnitTests/Views/ToplevelTests.cs | 7 - Tests/UnitTests/Views/WindowTests.cs | 10 - .../Drawing/Region/RegionTests.cs | 190 +++++- .../ParallelizableBase.cs | 11 + Tests/UnitTestsParallelizable/TestSetup.cs | 101 ++++ .../UnitTests.Parallelizable.csproj | 2 +- .../View/Adornment/AdornmentSubViewTests.cs | 1 + .../View/Adornment/AdornmentTests.cs | 1 + .../View/Adornment/ShadowStyletests.cs | 2 + .../View/Keyboard/HotKeyTests.cs | 43 +- .../View/Keyboard/KeyboardEventTests.cs | 1 + .../View/Layout/Dim.Tests.cs | 1 + .../View/Layout/SetLayoutTests.cs | 6 +- .../View/Mouse/MouseTests.cs | 2 + .../View/Navigation/AddRemoveTests.cs | 1 + .../View/Navigation/CanFocusTests.cs | 1 + .../View/Navigation/EnabledTests.cs | 1 + .../Navigation/HasFocusChangeEventTests.cs | 1 + .../View/Navigation/HasFocusTests.cs | 1 + .../View/Navigation/RestoreFocusTests.cs | 1 + .../View/Navigation/SetFocusTests.cs | 1 + .../View/Navigation/VisibleTests.cs | 1 + .../View/SubviewTests.cs | 67 ++ .../Views/AllViewsTests.cs | 14 +- .../Views/FlagSelectorTests.cs | 174 ++++-- .../Views/ShortcutTests.cs | 4 +- UICatalog/Scenario.cs | 2 - UICatalog/Scenarios/AllViewsTester.cs | 23 +- .../AnimationScenario/AnimationScenario.cs | 1 - UICatalog/Scenarios/ConfigurationEditor.cs | 9 +- UICatalog/Scenarios/ContextMenus.cs | 8 +- UICatalog/Scenarios/DynamicMenuBar.cs | 5 +- UICatalog/Scenarios/DynamicStatusBar.cs | 6 +- .../Scenarios/Editors/AdornmentEditor.cs | 6 +- UICatalog/Scenarios/Editors/BorderEditor.cs | 2 +- UICatalog/Scenarios/Editors/MarginEditor.cs | 4 +- UICatalog/Scenarios/Generic.cs | 15 +- UICatalog/Scenarios/HexEditor.cs | 70 +-- UICatalog/Scenarios/Menus.cs | 440 ++++++++++++++ UICatalog/Scenarios/MenusV2.cs | 538 ---------------- UICatalog/Scenarios/Shortcuts.cs | 64 +- UICatalog/Scenarios/SpinnerStyles.cs | 10 +- UICatalog/Scenarios/TextEffectsScenario.cs | 31 +- UICatalog/UICatalog.cs | 51 +- .../{UICatalogTopLevel.cs => UICatalogTop.cs} | 130 ++-- local_packages/Terminal.Gui.2.0.0.nupkg | Bin 814707 -> 843854 bytes local_packages/Terminal.Gui.2.0.0.snupkg | Bin 217053 -> 225881 bytes 136 files changed, 5109 insertions(+), 2214 deletions(-) create mode 100644 Terminal.Gui/Terminal.Gui.sln create mode 100644 Terminal.Gui/View/View.ColorScheme.cs create mode 100644 Terminal.Gui/Views/FlagSelectorTEnum.cs create mode 100644 Terminal.Gui/Views/OptionSelector.cs create mode 100644 Tests/IntegrationTests/FluentTests/MenuBarv2Tests.cs create mode 100644 Tests/IntegrationTests/FluentTests/PopverMenuTests.cs create mode 100644 Tests/UnitTestsParallelizable/ParallelizableBase.cs create mode 100644 Tests/UnitTestsParallelizable/TestSetup.cs create mode 100644 UICatalog/Scenarios/Menus.cs delete mode 100644 UICatalog/Scenarios/MenusV2.cs rename UICatalog/{UICatalogTopLevel.cs => UICatalogTop.cs} (88%) diff --git a/Terminal.Gui/Application/Application.Keyboard.cs b/Terminal.Gui/Application/Application.Keyboard.cs index eda91faf6..b2b6aa41a 100644 --- a/Terminal.Gui/Application/Application.Keyboard.cs +++ b/Terminal.Gui/Application/Application.Keyboard.cs @@ -13,6 +13,17 @@ public static partial class Application // Keyboard handling /// if the key was handled. public static bool RaiseKeyDownEvent (Key key) { + Logging.Debug ($"{key}"); + + // TODO: Add a way to ignore certain keys, esp for debugging. + //#if DEBUG + // if (key == Key.Empty.WithAlt || key == Key.Empty.WithCtrl) + // { + // Logging.Debug ($"Ignoring {key}"); + // return false; + // } + //#endif + // TODO: This should match standard event patterns KeyDown?.Invoke (null, key); diff --git a/Terminal.Gui/Application/Application.Mouse.cs b/Terminal.Gui/Application/Application.Mouse.cs index 72cffe368..acc5cbc95 100644 --- a/Terminal.Gui/Application/Application.Mouse.cs +++ b/Terminal.Gui/Application/Application.Mouse.cs @@ -63,7 +63,7 @@ public static partial class Application // Mouse handling } #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable) + if (View.EnableDebugIDisposableAsserts) { ObjectDisposedException.ThrowIf (MouseGrabView.WasDisposed, MouseGrabView); } @@ -154,7 +154,7 @@ public static partial class Application // Mouse handling if (deepestViewUnderMouse is { }) { #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable && deepestViewUnderMouse.WasDisposed) + if (View.EnableDebugIDisposableAsserts && deepestViewUnderMouse.WasDisposed) { throw new ObjectDisposedException (deepestViewUnderMouse.GetType ().FullName); } @@ -174,8 +174,11 @@ public static partial class Application // Mouse handling && Popover?.GetActivePopover () as View is { Visible: true } visiblePopover && View.IsInHierarchy (visiblePopover, deepestViewUnderMouse, includeAdornments: true) is false) { - - visiblePopover.Visible = false; + // TODO: Build a use/test case for the popover not handling Quit + if (visiblePopover.InvokeCommand (Command.Quit) is true && visiblePopover.Visible) + { + visiblePopover.Visible = false; + } // Recurse once so the event can be handled below the popover RaiseMouseEvent (mouseEvent); @@ -297,7 +300,7 @@ public static partial class Application // Mouse handling if (MouseGrabView is { }) { #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable && MouseGrabView.WasDisposed) + if (View.EnableDebugIDisposableAsserts && MouseGrabView.WasDisposed) { throw new ObjectDisposedException (MouseGrabView.GetType ().FullName); } diff --git a/Terminal.Gui/Application/Application.Run.cs b/Terminal.Gui/Application/Application.Run.cs index f4a4cf44e..d69a34e92 100644 --- a/Terminal.Gui/Application/Application.Run.cs +++ b/Terminal.Gui/Application/Application.Run.cs @@ -98,7 +98,7 @@ public static partial class Application // Run (Begin, Run, End, Stop) var rs = new RunState (toplevel); #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable && Top is { } && toplevel != Top && !TopLevels.Contains (Top)) + if (View.EnableDebugIDisposableAsserts && Top is { } && toplevel != Top && !TopLevels.Contains (Top)) { // This assertion confirm if the Top was already disposed Debug.Assert (Top.WasDisposed); @@ -193,6 +193,11 @@ public static partial class Application // Run (Begin, Run, End, Stop) toplevel.EndInit (); // Calls Layout } + // Call ConfigurationManager Apply here to ensure all subscribers to ConfigurationManager.Applied + // can update their state appropriately. + // BUGBUG: DO NOT DO THIS. Leave this commented out until we can figure out how to do this right + //Apply (); + // Try to set initial focus to any TabStop if (!toplevel.HasFocus) { @@ -426,7 +431,7 @@ public static partial class Application // Run (Begin, Run, End, Stop) internal static void LayoutAndDrawImpl (bool forceDraw = false) { - List tops = [..TopLevels]; + List tops = [.. TopLevels]; if (Popover?.GetActivePopover () as View is { Visible: true } visiblePopover) { @@ -479,7 +484,10 @@ public static partial class Application // Run (Begin, Run, End, Stop) for (state.Toplevel.Running = true; state.Toplevel?.Running == true;) { - MainLoop!.Running = true; + if (MainLoop is { }) + { + MainLoop.Running = true; + } if (EndAfterFirstIteration && !firstIteration) { @@ -489,7 +497,10 @@ public static partial class Application // Run (Begin, Run, End, Stop) firstIteration = RunIteration (ref state, firstIteration); } - MainLoop!.Running = false; + if (MainLoop is { }) + { + MainLoop.Running = false; + } // Run one last iteration to consume any outstanding input events from Driver // This is important for remaining OnKeyUp events. @@ -505,7 +516,7 @@ public static partial class Application // Run (Begin, Run, End, Stop) public static bool RunIteration (ref RunState state, bool firstIteration = false) { // If the driver has events pending do an iteration of the driver MainLoop - if (MainLoop!.Running && MainLoop.EventsPending ()) + if (MainLoop is { Running: true } && MainLoop.EventsPending ()) { // Notify Toplevel it's ready if (firstIteration) @@ -529,7 +540,7 @@ public static partial class Application // Run (Begin, Run, End, Stop) if (PositionCursor ()) { - Driver!.UpdateCursor (); + Driver?.UpdateCursor (); } return firstIteration; @@ -564,7 +575,14 @@ public static partial class Application // Run (Begin, Run, End, Stop) { ArgumentNullException.ThrowIfNull (runState); - Popover?.Hide (Popover?.GetActivePopover ()); + if (Popover?.GetActivePopover () as View is { Visible: true } visiblePopover) + { + // TODO: Build a use/test case for the popover not handling Quit + if (visiblePopover.InvokeCommand (Command.Quit) is true && visiblePopover.Visible) + { + visiblePopover.Visible = false; + } + } runState.Toplevel.OnUnloaded (); diff --git a/Terminal.Gui/Application/Application.cs b/Terminal.Gui/Application/Application.cs index 07bec47c7..f11104c8c 100644 --- a/Terminal.Gui/Application/Application.cs +++ b/Terminal.Gui/Application/Application.cs @@ -53,6 +53,7 @@ public static partial class Application { return string.Empty; } + var sb = new StringBuilder (); Cell [,] contents = driver?.Contents!; @@ -139,7 +140,7 @@ public static partial class Application // starts running and after Shutdown returns. internal static void ResetState (bool ignoreDisposed = false) { - Application.Navigation = new ApplicationNavigation (); + Navigation = new (); // Shutdown is the bookend for Init. As such it needs to clean up all resources // Init created. Apps that do any threading will need to code defensively for this. @@ -151,8 +152,11 @@ public static partial class Application if (Popover?.GetActivePopover () is View popover) { + // This forcefully closes the popover; invoking Command.Quit would be more graceful + // but since this is shutdown, doing this is ok. popover.Visible = false; } + Popover?.Dispose (); Popover = null; @@ -160,9 +164,9 @@ public static partial class Application #if DEBUG_IDISPOSABLE // Don't dispose the Top. It's up to caller dispose it - if (View.DebugIDisposable && !ignoreDisposed && Top is { }) + if (View.EnableDebugIDisposableAsserts && !ignoreDisposed && Top is { }) { - Debug.Assert (Top.WasDisposed); + Debug.Assert (Top.WasDisposed, $"Title = {Top.Title}, Id = {Top.Id}"); // If End wasn't called _cachedRunStateToplevel may be null if (_cachedRunStateToplevel is { }) @@ -223,7 +227,6 @@ public static partial class Application Navigation = null; - KeyBindings.Clear (); AddKeyBindings (); @@ -234,10 +237,9 @@ public static partial class Application SynchronizationContext.SetSynchronizationContext (null); } - /// /// Adds specified idle handler function to main iteration processing. The handler function will be called /// once per iteration of the main loop after other events have been handled. /// - public static void AddIdle (Func func) => ApplicationImpl.Instance.AddIdle (func); + public static void AddIdle (Func func) { ApplicationImpl.Instance.AddIdle (func); } } diff --git a/Terminal.Gui/Application/ApplicationImpl.cs b/Terminal.Gui/Application/ApplicationImpl.cs index 8bbc17ccb..04c5eed6f 100644 --- a/Terminal.Gui/Application/ApplicationImpl.cs +++ b/Terminal.Gui/Application/ApplicationImpl.cs @@ -34,7 +34,7 @@ public class ApplicationImpl : IApplication [RequiresDynamicCode ("AOT")] public virtual void Init (IConsoleDriver? driver = null, string? driverName = null) { - Application.InternalInit (driver, driverName); + Application.InternalInit (driver, driverName); } /// @@ -166,34 +166,35 @@ public class ApplicationImpl : IApplication try { #endif - resume = false; - RunState runState = Application.Begin (view); + resume = false; + RunState runState = Application.Begin (view); - // If EndAfterFirstIteration is true then the user must dispose of the runToken - // by using NotifyStopRunState event. - Application.RunLoop (runState); + // If EndAfterFirstIteration is true then the user must dispose of the runToken + // by using NotifyStopRunState event. + Application.RunLoop (runState); - if (runState.Toplevel is null) - { + if (runState.Toplevel is null) + { #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable) + if (View.EnableDebugIDisposableAsserts) { Debug.Assert (Application.TopLevels.Count == 0); } #endif - runState.Dispose (); + runState.Dispose (); - return; - } + return; + } - if (!Application.EndAfterFirstIteration) - { - Application.End (runState); - } + if (!Application.EndAfterFirstIteration) + { + Application.End (runState); + } #if !DEBUG } catch (Exception error) { + Logging.Warning ($"Release Build Exception: {error}"); if (errorHandler is null) { throw; @@ -225,7 +226,7 @@ public class ApplicationImpl : IApplication { bool init = Application.Initialized; - Application.OnInitializedChanged(this, new (in init)); + Application.OnInitializedChanged (this, new (in init)); } } @@ -270,7 +271,7 @@ public class ApplicationImpl : IApplication /// public virtual void AddIdle (Func func) { - if(Application.MainLoop is null) + if (Application.MainLoop is null) { throw new NotInitializedException ("Cannot add idle before main loop is initialized"); } @@ -294,7 +295,7 @@ public class ApplicationImpl : IApplication /// public virtual bool RemoveTimeout (object token) - { + { return Application.MainLoop?.TimedEvents.RemoveTimeout (token) ?? false; } diff --git a/Terminal.Gui/Application/ApplicationPopover.cs b/Terminal.Gui/Application/ApplicationPopover.cs index 1124faefb..b8238140b 100644 --- a/Terminal.Gui/Application/ApplicationPopover.cs +++ b/Terminal.Gui/Application/ApplicationPopover.cs @@ -103,6 +103,7 @@ public sealed class ApplicationPopover : IDisposable if (popover is View newPopover) { + Register (popover); if (!newPopover.IsInitialized) { newPopover.BeginInit (); @@ -145,6 +146,7 @@ public sealed class ApplicationPopover : IDisposable if (activePopover is { Visible: true }) { + Logging.Debug ($"Active - Calling NewKeyDownEvent ({key}) on {activePopover.Title}"); if (activePopover.NewKeyDownEvent (key)) { return true; @@ -163,6 +165,7 @@ public sealed class ApplicationPopover : IDisposable } // hotKeyHandled = popoverView.InvokeCommandsBoundToHotKey (key); + Logging.Debug ($"Inactive - Calling NewKeyDownEvent ({key}) on {popoverView.Title}"); hotKeyHandled = popoverView.NewKeyDownEvent (key); if (hotKeyHandled is true) diff --git a/Terminal.Gui/Application/PopoverBaseImpl.cs b/Terminal.Gui/Application/PopoverBaseImpl.cs index 64b90532c..dfa05c897 100644 --- a/Terminal.Gui/Application/PopoverBaseImpl.cs +++ b/Terminal.Gui/Application/PopoverBaseImpl.cs @@ -41,7 +41,7 @@ public abstract class PopoverBaseImpl : View, IPopover { if (!Visible) { - return null; + return false; } Visible = false; @@ -54,11 +54,22 @@ public abstract class PopoverBaseImpl : View, IPopover protected override bool OnVisibleChanging () { bool ret = base.OnVisibleChanging (); - if (!ret && !Visible) + if (ret is not true) { - // Whenever visible is changing to true, we need to resize; - // it's our only chance because we don't get laid out until we're visible - Layout (Application.Screen.Size); + if (!Visible) + { + // Whenever visible is changing to true, we need to resize; + // it's our only chance because we don't get laid out until we're visible + Layout (Application.Screen.Size); + } + else + { + // Whenever visible is changing to false, we need to reset the focus + if (ApplicationNavigation.IsInHierarchy(this, Application.Navigation?.GetFocused ())) + { + Application.Navigation?.SetFocused (Application.Top?.MostFocused); + } + } } return ret; diff --git a/Terminal.Gui/Application/RunState.cs b/Terminal.Gui/Application/RunState.cs index e0b6fdc30..503055892 100644 --- a/Terminal.Gui/Application/RunState.cs +++ b/Terminal.Gui/Application/RunState.cs @@ -1,4 +1,6 @@ -namespace Terminal.Gui; +using System.Collections.Concurrent; + +namespace Terminal.Gui; /// The execution state for a view. public class RunState : IDisposable @@ -22,10 +24,7 @@ public class RunState : IDisposable Dispose (true); GC.SuppressFinalize (this); #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable) - { - WasDisposed = true; - } + WasDisposed = true; #endif } @@ -45,22 +44,32 @@ public class RunState : IDisposable } #if DEBUG_IDISPOSABLE - /// For debug (see DEBUG_IDISPOSABLE define) purposes to verify objects are being disposed properly - public bool WasDisposed; + /// + /// Gets whether was called on this RunState or not. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public bool WasDisposed { get; private set; } - /// For debug (see DEBUG_IDISPOSABLE define) purposes to verify objects are being disposed properly - public int DisposedCount = 0; + /// + /// Gets the number of times was called on this object. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public int DisposedCount { get; private set; } = 0; - /// For debug (see DEBUG_IDISPOSABLE define) purposes; the runstate instances that have been created - public static List Instances = new (); + /// + /// Gets the list of RunState objects that have been created and not yet disposed. + /// Note, this is a static property and will affect all RunState objects. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public static ConcurrentBag Instances { get; private set; } = []; /// Creates a new RunState object. public RunState () { - if (View.DebugIDisposable) - { - Instances.Add (this); - } + Instances.Add (this); } #endif } diff --git a/Terminal.Gui/Configuration/AttributeJsonConverter.cs b/Terminal.Gui/Configuration/AttributeJsonConverter.cs index ff1797221..ba291c75a 100644 --- a/Terminal.Gui/Configuration/AttributeJsonConverter.cs +++ b/Terminal.Gui/Configuration/AttributeJsonConverter.cs @@ -92,7 +92,7 @@ internal class AttributeJsonConverter : JsonConverter } } - throw new JsonException (); + throw new JsonException ("Attribute"); } public override void Write (Utf8JsonWriter writer, Attribute value, JsonSerializerOptions options) diff --git a/Terminal.Gui/Configuration/ConfigurationManager.cs b/Terminal.Gui/Configuration/ConfigurationManager.cs index b2b5f4766..82c01336a 100644 --- a/Terminal.Gui/Configuration/ConfigurationManager.cs +++ b/Terminal.Gui/Configuration/ConfigurationManager.cs @@ -258,6 +258,7 @@ public static class ConfigurationManager Settings?.UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!, ConfigLocations.AppResources); } + // TODO: Determine if Runtime should be applied last. if (Locations.HasFlag (ConfigLocations.Runtime) && !string.IsNullOrEmpty (RuntimeConfig)) { Settings?.Update (RuntimeConfig, "ConfigurationManager.RuntimeConfig", ConfigLocations.Runtime); diff --git a/Terminal.Gui/Configuration/ScopeJsonConverter.cs b/Terminal.Gui/Configuration/ScopeJsonConverter.cs index d1d6e475e..3c5a2c856 100644 --- a/Terminal.Gui/Configuration/ScopeJsonConverter.cs +++ b/Terminal.Gui/Configuration/ScopeJsonConverter.cs @@ -96,6 +96,8 @@ internal class ScopeJsonConverter<[DynamicallyAccessedMembers (DynamicallyAccess // Logging.Trace ($"scopeT Read: {ex}"); } } + //Logging.Warning ($"{propertyName} = {scope! [propertyName].PropertyValue}"); + } else { @@ -147,7 +149,7 @@ internal class ScopeJsonConverter<[DynamicallyAccessedMembers (DynamicallyAccess } } - throw new JsonException (); + throw new JsonException ("ScopeJsonConverter"); } public override void Write (Utf8JsonWriter writer, scopeT scope, JsonSerializerOptions options) diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs b/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs index a83b84921..c19c5e1be 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs @@ -6,7 +6,7 @@ namespace Terminal.Gui; internal class NetEvents : IDisposable { - private readonly CancellationTokenSource _netEventsDisposed = new CancellationTokenSource (); + private CancellationTokenSource? _netEventsDisposed = new CancellationTokenSource (); //CancellationTokenSource _waitForStartCancellationTokenSource; private readonly ManualResetEventSlim _winChange = new (false); @@ -597,6 +597,7 @@ internal class NetEvents : IDisposable { _netEventsDisposed?.Cancel (); _netEventsDisposed?.Dispose (); + _netEventsDisposed = null; try { diff --git a/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs b/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs index e6461b144..af5e912c2 100644 --- a/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs +++ b/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs @@ -162,7 +162,7 @@ public class ApplicationV2 : ApplicationImpl /// public override void Run (Toplevel view, Func? errorHandler = null) { - Logging.Logger.LogInformation ($"Run '{view}'"); + Logging.Information ($"Run '{view}'"); ArgumentNullException.ThrowIfNull (view); if (!Application.Initialized) @@ -172,10 +172,12 @@ public class ApplicationV2 : ApplicationImpl Application.Top = view; - Application.Begin (view); + RunState rs = Application.Begin (view); - // TODO : how to know when we are done? - while (Application.TopLevels.TryPeek (out Toplevel? found) && found == view) + Application.Top.Running = true; + + // QUESTION: how to know when we are done? - ANSWER: Running == false + while (Application.TopLevels.TryPeek (out Toplevel? found) && found == view && view.Running) { if (_coordinator is null) { @@ -184,6 +186,9 @@ public class ApplicationV2 : ApplicationImpl _coordinator.RunIteration (); } + + Logging.Information ($"Run - Calling End"); + Application.End (rs); } /// @@ -197,7 +202,7 @@ public class ApplicationV2 : ApplicationImpl /// public override void RequestStop (Toplevel? top) { - Logging.Logger.LogInformation ($"RequestStop '{top}'"); + Logging.Logger.LogInformation ($"RequestStop '{(top is {} ? top : "null")}'"); top ??= Application.Top; @@ -214,22 +219,9 @@ public class ApplicationV2 : ApplicationImpl return; } + // All RequestStop does is set the Running property to false - In the next iteration + // this will be detected top.Running = false; - - // TODO: This definition of stop seems sketchy - Application.TopLevels.TryPop (out _); - - if (Application.TopLevels.Count > 0) - { - Application.Top = Application.TopLevels.Peek (); - } - else - { - Application.Top = null; - } - - // Notify that it is closed - top.OnClosed (top); } /// diff --git a/Terminal.Gui/Drawing/Attribute.cs b/Terminal.Gui/Drawing/Attribute.cs index a36601ba1..28097f377 100644 --- a/Terminal.Gui/Drawing/Attribute.cs +++ b/Terminal.Gui/Drawing/Attribute.cs @@ -4,6 +4,11 @@ using System.Text.Json.Serialization; namespace Terminal.Gui; + +// TODO: Add support for other attributes (bold, underline, etc.) once the platform drivers support them. +// TODO: See https://github.com/gui-cs/Terminal.Gui/issues/457 + + /// Attributes represent how text is styled when displayed in the terminal. /// /// provides a platform independent representation of colors (and someday other forms of diff --git a/Terminal.Gui/Drawing/Color/ColorScheme.cs b/Terminal.Gui/Drawing/Color/ColorScheme.cs index 2e83a4c14..adb47f143 100644 --- a/Terminal.Gui/Drawing/Color/ColorScheme.cs +++ b/Terminal.Gui/Drawing/Color/ColorScheme.cs @@ -4,6 +4,9 @@ using System.Text.Json.Serialization; namespace Terminal.Gui; +// TODO: Rename "ColorScheme"->"AttributeScheme" given we'll soon have non-color information in Attributes? +// TODO: See https://github.com/gui-cs/Terminal.Gui/issues/457 + /// Defines a standard set of s for common visible elements in a . /// /// diff --git a/Terminal.Gui/Drawing/Region.cs b/Terminal.Gui/Drawing/Region.cs index 2e5131975..1cfea8638 100644 --- a/Terminal.Gui/Drawing/Region.cs +++ b/Terminal.Gui/Drawing/Region.cs @@ -556,72 +556,122 @@ public class Region /// A list of merged rectangles. internal static List MergeRectangles (List rectangles, bool minimize) { - if (rectangles.Count == 0) + if (rectangles.Count <= 1) { - return []; + return rectangles.ToList (); } - // Sweep-line algorithm to merge rectangles - List<(int x, bool isStart, int yTop, int yBottom)> events = new (rectangles.Count * 2); // Pre-allocate - + // Generate events + List<(int x, bool isStart, int yTop, int yBottom)> events = new (rectangles.Count * 2); foreach (Rectangle r in rectangles) { if (!r.IsEmpty) { - events.Add ((r.Left, true, r.Top, r.Bottom)); // Start event - events.Add ((r.Right, false, r.Top, r.Bottom)); // End event + events.Add ((r.Left, true, r.Top, r.Bottom)); + events.Add ((r.Right, false, r.Top, r.Bottom)); } } if (events.Count == 0) { - return []; // Return empty list if no non-empty rectangles exist + return []; } + // Sort events: + // 1. Primarily by x-coordinate. + // 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) => { + // 1. Sort by X int cmp = a.x.CompareTo (b.x); + if (cmp != 0) return cmp; - if (cmp != 0) - { - return cmp; - } + // 2. Sort End events before Start events + 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) - return a.isStart.CompareTo (b.isStart); // Start events before end events at same x + // 3. Tie-breaker: Sort by yTop + cmp = a.yTop.CompareTo (b.yTop); + if (cmp != 0) return cmp; + + // 4. Final Tie-breaker: Sort by yBottom + return a.yBottom.CompareTo (b.yBottom); }); 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); + }); - SortedSet<(int yTop, int yBottom)> active = new ( - Comparer<(int yTop, int yBottom)>.Create ( - (a, b) => - { - int cmp = a.yTop.CompareTo (b.yTop); - - return cmp != 0 ? cmp : a.yBottom.CompareTo (b.yBottom); - })); - int lastX = events [0].x; - - foreach ((int x, bool isStart, int yTop, int yBottom) evt in events) + // Helper to get the current active intervals (where count > 0) as a SortedSet + SortedSet<(int yTop, int yBottom)> GetActiveIntervals () { - // Output rectangles for the previous segment if there are active rectangles - if (active.Count > 0 && evt.x > lastX) + var set = new SortedSet<(int yTop, int yBottom)> (intervalComparer); + foreach (var kvp in activeCounts) { - merged.AddRange (MergeVerticalIntervals (active, lastX, evt.x)); + if (kvp.Value > 0) + { + set.Add (kvp.Key); + } + } + return set; + } + + // 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); + int lastX = groupedEvents.First ().Key; // Initialize with the first event's x + + foreach (var group in groupedEvents) + { + int currentX = group.Key; + // Get active intervals based on state *before* processing events at currentX + var currentActiveIntervals = GetActiveIntervals (); + + // 1. Output rectangles for the segment ending *before* this x coordinate + if (currentX > lastX && currentActiveIntervals.Count > 0) + { + merged.AddRange (MergeVerticalIntervals (currentActiveIntervals, lastX, currentX)); } - // Process the event - if (evt.isStart) + // 2. Process all events *at* this x coordinate to update counts + foreach (var evt in group) { - active.Add ((evt.yTop, evt.yBottom)); - } - else - { - active.Remove ((evt.yTop, evt.yBottom)); + var interval = (evt.yTop, evt.yBottom); + if (evt.isStart) + { + activeCounts.TryGetValue (interval, out int count); + activeCounts [interval] = count + 1; + } + else + { + // Only decrement/remove if the interval exists + if (activeCounts.TryGetValue (interval, out int count)) + { + if (count - 1 <= 0) + { + activeCounts.Remove (interval); + } + else + { + activeCounts [interval] = count - 1; + } + } + } } - lastX = evt.x; + // 3. Update lastX for the next segment + lastX = currentX; } return minimize ? MinimizeRectangles (merged) : merged; diff --git a/Terminal.Gui/Resources/config.json b/Terminal.Gui/Resources/config.json index 230533b29..df672d65c 100644 --- a/Terminal.Gui/Resources/config.json +++ b/Terminal.Gui/Resources/config.json @@ -52,6 +52,9 @@ "MessageBox.DefaultButtonAlignment": "Center", "MessageBox.DefaultBorderStyle": "Heavy", "Button.DefaultShadow": "Opaque", + "Menuv2.DefaultBorderStyle": "Single", + "MenuBarv2.DefaultBorderStyle": "None", + "StatusBar.DefaultSeparatorLineStyle": "Single", "ColorSchemes": [ { "TopLevel": { @@ -132,16 +135,16 @@ "Background": "DarkBlue" }, "Focus": { - "Foreground": "White", - "Background": "Blue" + "Foreground": "DarkBlue", + "Background": "White" }, "HotNormal": { "Foreground": "Yellow", "Background": "DarkBlue" }, "HotFocus": { - "Foreground": "Yellow", - "Background": "Blue" + "Foreground": "Blue", + "Background": "White" }, "Disabled": { "Foreground": "Gray", @@ -853,6 +856,18 @@ } ] } + }, + { + "Minimal": { + "Dialog.DefaultShadow": "None", + "FrameView.DefaultBorderStyle": "None", + "Window.DefaultBorderStyle": "None", + "MessageBox.DefaultBorderStyle": "None", + "Button.DefaultShadow": "None", + "Menuv2.DefaultBorderStyle": "None", + "Glyphs.LeftBracket": "[", + "Glyphs.RightBracket": "]" + } } ] } \ No newline at end of file diff --git a/Terminal.Gui/Terminal.Gui.sln b/Terminal.Gui/Terminal.Gui.sln new file mode 100644 index 000000000..7724d3f2e --- /dev/null +++ b/Terminal.Gui/Terminal.Gui.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui", "Terminal.Gui.csproj", "{79692A4F-7704-552C-0EF5-40B81C4F2E81}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {79692A4F-7704-552C-0EF5-40B81C4F2E81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79692A4F-7704-552C-0EF5-40B81C4F2E81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79692A4F-7704-552C-0EF5-40B81C4F2E81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79692A4F-7704-552C-0EF5-40B81C4F2E81}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4B162456-436F-4899-B765-26C9DBD2991D} + EndGlobalSection +EndGlobal diff --git a/Terminal.Gui/View/Adornment/Border.cs b/Terminal.Gui/View/Adornment/Border.cs index 0cb68c94f..9700e7e14 100644 --- a/Terminal.Gui/View/Adornment/Border.cs +++ b/Terminal.Gui/View/Adornment/Border.cs @@ -201,6 +201,7 @@ public class Border : Adornment ); } + // TODO: Make LineStyle nullable https://github.com/gui-cs/Terminal.Gui/issues/4021 /// /// Sets the style of the border by changing the . This is a helper API for setting the /// to (1,1,1,1) and setting the line style of the views that comprise the border. If diff --git a/Terminal.Gui/View/Adornment/Margin.cs b/Terminal.Gui/View/Adornment/Margin.cs index 9d5dc44e8..25ad7bf86 100644 --- a/Terminal.Gui/View/Adornment/Margin.cs +++ b/Terminal.Gui/View/Adornment/Margin.cs @@ -104,25 +104,86 @@ public class Margin : Adornment ShadowStyle = base.ShadowStyle; } - /// - /// The color scheme for the Margin. If set to (the default), the margin will be transparent. - /// - public override ColorScheme? ColorScheme - { - get - { - if (base.ColorScheme is { }) - { - return base.ColorScheme; - } + // TODO: We may actualy need this. Not clear what broke, if anything by commenting it out. See https://github.com/gui-cs/Terminal.Gui/issues/4016 + /////// + /////// The color scheme for the Margin. If set to (the default), the margin will be transparent. + /////// + //public override ColorScheme? ColorScheme + //{ + // get + // { + // //if (base.ColorScheme is { }) + // { + // return base.ColorScheme; + // } - return (Parent?.SuperView?.ColorScheme ?? Colors.ColorSchemes ["TopLevel"])!; - } - set + // //return (Parent?.SuperView?.ColorScheme ?? Colors.ColorSchemes ["TopLevel"])!; + // } + // set + // { + // base.ColorScheme = value; + // Parent?.SetNeedsDraw (); + // } + //} + + /// + public override Attribute GetNormalColor () + { + if (_colorScheme is { }) { - base.ColorScheme = value; - Parent?.SetNeedsDraw (); + return _colorScheme.Normal; } + if (Parent is { }) + { + return Parent.GetNormalColor (); + } + + return base.GetNormalColor (); + } + + /// + public override Attribute GetHotNormalColor () + { + if (Parent is { }) + { + return Parent.GetHotNormalColor (); + } + return base.GetHotNormalColor (); + } + + /// + public override Attribute GetFocusColor () + { + if (Parent is { }) + { + return Parent.GetFocusColor (); + } + return base.GetFocusColor (); + } + + /// + public override Attribute GetHotFocusColor () + { + if (Parent is { }) + { + return Parent.GetHotFocusColor (); + } + + return base.GetHotFocusColor (); + } + + /// + protected override bool OnSettingNormalAttribute () + { + if (Parent is { }) + { + SetAttribute (Parent.GetNormalColor ()); + + return true; + } + + return false; + } /// @@ -138,6 +199,8 @@ public class Margin : Adornment // This just draws/clears the thickness, not the insides. if (Diagnostics.HasFlag (ViewDiagnosticFlags.Thickness) || base.ColorScheme is { }) { + // TODO: This is a hack. See https://github.com/gui-cs/Terminal.Gui/issues/4016 + SetAttribute (GetNormalColor ()); Thickness.Draw (screen, Diagnostics, ToString ()); } diff --git a/Terminal.Gui/View/Adornment/ShadowView.cs b/Terminal.Gui/View/Adornment/ShadowView.cs index fa158cbdf..284d6da8e 100644 --- a/Terminal.Gui/View/Adornment/ShadowView.cs +++ b/Terminal.Gui/View/Adornment/ShadowView.cs @@ -54,6 +54,7 @@ internal class ShadowView : View /// protected override bool OnDrawingContent () { + SetAttribute (GetNormalColor ()); switch (ShadowStyle) { case ShadowStyle.Opaque: diff --git a/Terminal.Gui/View/IDesignable.cs b/Terminal.Gui/View/IDesignable.cs index febaaf45e..13d8cbae6 100644 --- a/Terminal.Gui/View/IDesignable.cs +++ b/Terminal.Gui/View/IDesignable.cs @@ -12,7 +12,7 @@ public interface IDesignable /// Optional arbitrary, View-specific, context. /// A non-null type for . /// if the view successfully loaded demo data. - public bool EnableForDesign (ref readonly TContext context) where TContext : notnull => EnableForDesign (); + public bool EnableForDesign (ref TContext context) where TContext : notnull => EnableForDesign (); /// /// Causes the View to enable design-time mode. This typically means that the view will load demo data and diff --git a/Terminal.Gui/View/View.Adornments.cs b/Terminal.Gui/View/View.Adornments.cs index 9e0549839..c2d728058 100644 --- a/Terminal.Gui/View/View.Adornments.cs +++ b/Terminal.Gui/View/View.Adornments.cs @@ -121,6 +121,7 @@ public partial class View // Adornments /// public Border? Border { get; private set; } + // TODO: Make BorderStyle nullable https://github.com/gui-cs/Terminal.Gui/issues/4021 /// Gets or sets whether the view has a one row/col thick border. /// /// @@ -133,7 +134,7 @@ public partial class View // Adornments /// to `0` and to . /// /// - /// Calls and raises , which allows change + /// Raises and raises , which allows change /// to be cancelled. /// /// For more advanced customization of the view's border, manipulate see directly. @@ -148,44 +149,21 @@ public partial class View // Adornments return; } - LineStyle old = Border?.LineStyle ?? LineStyle.None; - - // It's tempting to try to optimize this by checking that old != value and returning. - // Do not. - - CancelEventArgs e = new (ref old, ref value); - - if (OnBorderStyleChanging (e) || e.Cancel) - { - return; - } - - BorderStyleChanging?.Invoke (this, e); - - if (e.Cancel) - { - return; - } - - SetBorderStyle (e.NewValue); - SetAdornmentFrames (); - SetNeedsLayout (); + SetBorderStyle (value); + OnBorderStyleChanged (); + BorderStyleChanged?.Invoke (this, EventArgs.Empty); } } /// - /// Called when the is changing. + /// Called when the has changed. /// - /// - /// Set e.Cancel to true to prevent the from changing. - /// - /// - protected virtual bool OnBorderStyleChanging (CancelEventArgs e) { return false; } + protected virtual bool OnBorderStyleChanged () { return false; } /// - /// Fired when the is changing. Allows the event to be cancelled. + /// Fired when the has changed. /// - public event EventHandler>? BorderStyleChanging; + public event EventHandler? BorderStyleChanged; /// /// Sets the of the view to the specified value. @@ -204,7 +182,7 @@ public partial class View // Adornments /// For more advanced customization of the view's border, manipulate see directly. /// /// - public virtual void SetBorderStyle (LineStyle style) + internal void SetBorderStyle (LineStyle style) { if (style != LineStyle.None) { @@ -219,6 +197,9 @@ public partial class View // Adornments } Border.LineStyle = style; + + SetAdornmentFrames (); + SetNeedsLayout (); } /// diff --git a/Terminal.Gui/View/View.Attribute.cs b/Terminal.Gui/View/View.Attribute.cs index 20e201b66..02ac0f3e5 100644 --- a/Terminal.Gui/View/View.Attribute.cs +++ b/Terminal.Gui/View/View.Attribute.cs @@ -1,121 +1,21 @@ #nullable enable +using System.ComponentModel; + namespace Terminal.Gui; public partial class View { - // TODO: Rename "Color"->"Attribute" given we'll soon have non-color information in Attributes? - // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/457 - - #region ColorScheme - - private ColorScheme? _colorScheme; - - /// The color scheme for this view, if it is not defined, it returns the 's color scheme. - public virtual ColorScheme? ColorScheme - { - get => _colorScheme ?? SuperView?.ColorScheme; - set - { - if (_colorScheme == value) - { - return; - } - - _colorScheme = value; - - // BUGBUG: This should be in Border.cs somehow - if (Border is { } && Border.LineStyle != LineStyle.None && Border.ColorScheme is { }) - { - Border.ColorScheme = _colorScheme; - } - - SetNeedsDraw (); - } - } - - /// Determines the current based on the value. - /// - /// if is or - /// if is . If it's - /// overridden can return other values. - /// - public virtual Attribute GetFocusColor () - { - ColorScheme? cs = ColorScheme ?? new (); - - return Enabled ? GetColor (cs.Focus) : cs.Disabled; - } - - /// Determines the current based on the value. - /// - /// if is or - /// if is . If it's - /// overridden can return other values. - /// - public virtual Attribute GetHotFocusColor () - { - ColorScheme? cs = ColorScheme ?? new (); - - return Enabled ? GetColor (cs.HotFocus) : cs.Disabled; - } - - /// Determines the current based on the value. - /// - /// if is or - /// if is . If it's - /// overridden can return other values. - /// - public virtual Attribute GetHotNormalColor () - { - ColorScheme? cs = ColorScheme ?? new (); - - return Enabled ? GetColor (cs.HotNormal) : cs.Disabled; - } - - /// Determines the current based on the value. - /// - /// if is or - /// if is . If it's - /// overridden can return other values. - /// - public virtual Attribute GetNormalColor () - { - ColorScheme? cs = ColorScheme ?? new (); - - Attribute disabled = new (cs.Disabled.Foreground, cs.Disabled.Background); - - if (Diagnostics.HasFlag (ViewDiagnosticFlags.Hover) && _hovering) - { - disabled = new (disabled.Foreground.GetDarkerColor (), disabled.Background.GetDarkerColor ()); - } - - return Enabled ? GetColor (cs.Normal) : disabled; - } - - private Attribute GetColor (Attribute inputAttribute) - { - Attribute attr = inputAttribute; - - if (Diagnostics.HasFlag (ViewDiagnosticFlags.Hover) && _hovering) - { - attr = new (attr.Foreground.GetDarkerColor (), attr.Background.GetDarkerColor ()); - } - - return attr; - } - - #endregion ColorScheme - - #region Attribute - /// Selects the specified attribute as the attribute to use for future calls to AddRune and AddString. /// /// THe Attribute to set. - public Attribute SetAttribute (Attribute attribute) { return Driver?.SetAttribute (attribute) ?? Attribute.Default; } + public Attribute SetAttribute (Attribute attribute) + { + return Driver?.SetAttribute (attribute) ?? Attribute.Default; + } /// Gets the current . /// The current attribute. public Attribute GetAttribute () { return Driver?.GetAttribute () ?? Attribute.Default; } - #endregion Attribute + } diff --git a/Terminal.Gui/View/View.ColorScheme.cs b/Terminal.Gui/View/View.ColorScheme.cs new file mode 100644 index 000000000..14abe6fbd --- /dev/null +++ b/Terminal.Gui/View/View.ColorScheme.cs @@ -0,0 +1,211 @@ +#nullable enable +using System.ComponentModel; + +namespace Terminal.Gui; + +public partial class View +{ + // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/4014 + // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/4016 + // TODO: Enable ability to tell if ColorScheme was explicitly set; ColorScheme, as is, hides this. + internal ColorScheme? _colorScheme; + + /// The color scheme for this view, if it is not defined, it returns the 's color scheme. + public virtual ColorScheme? ColorScheme + { + // BUGBUG: This prevents the ability to know if ColorScheme was explicitly set or not. + get => _colorScheme ?? SuperView?.ColorScheme; + set + { + if (_colorScheme == value) + { + return; + } + + _colorScheme = value; + + // BUGBUG: This should be in Border.cs somehow + if (Border is { } && Border.LineStyle != LineStyle.None && Border.ColorScheme is { }) + { + Border.ColorScheme = _colorScheme; + } + + SetNeedsDraw (); + } + } + + /// Determines the current based on the value. + /// + /// if is or + /// if is . If it's + /// overridden can return other values. + /// + public virtual Attribute GetFocusColor () + { + Attribute currAttribute = ColorScheme?.Normal ?? Attribute.Default; + var newAttribute = new Attribute (); + CancelEventArgs args = new (in currAttribute, ref newAttribute); + GettingFocusColor?.Invoke (this, args); + + if (args.Cancel) + { + return args.NewValue; + } + + ColorScheme? cs = ColorScheme ?? new (); + + return Enabled ? GetColor (cs.Focus) : cs.Disabled; + } + + /// + /// Raised the Focus Color is being retrieved, from . Cancel the event and set the new + /// attribute in the event args to + /// a different value to change the focus color. + /// + public event EventHandler>? GettingFocusColor; + + /// Determines the current based on the value. + /// + /// if is or + /// if is . If it's + /// overridden can return other values. + /// + public virtual Attribute GetHotFocusColor () + { + Attribute currAttribute = ColorScheme?.Normal ?? Attribute.Default; + var newAttribute = new Attribute (); + CancelEventArgs args = new (in currAttribute, ref newAttribute); + GettingHotFocusColor?.Invoke (this, args); + + if (args.Cancel) + { + return args.NewValue; + } + + ColorScheme? cs = ColorScheme ?? new (); + + return Enabled ? GetColor (cs.HotFocus) : cs.Disabled; + } + + /// + /// Raised the HotFocus Color is being retrieved, from . Cancel the event and set the new + /// attribute in the event args to + /// a different value to change the focus color. + /// + public event EventHandler>? GettingHotFocusColor; + + /// Determines the current based on the value. + /// + /// if is or + /// if is . If it's + /// overridden can return other values. + /// + public virtual Attribute GetHotNormalColor () + { + Attribute currAttribute = ColorScheme?.Normal ?? Attribute.Default; + var newAttribute = new Attribute (); + CancelEventArgs args = new (in currAttribute, ref newAttribute); + GettingHotNormalColor?.Invoke (this, args); + + if (args.Cancel) + { + return args.NewValue; + } + + ColorScheme? cs = ColorScheme ?? new (); + + return Enabled ? GetColor (cs.HotNormal) : cs.Disabled; + } + + /// + /// Raised the HotNormal Color is being retrieved, from . Cancel the event and set the + /// new attribute in the event args to + /// a different value to change the focus color. + /// + public event EventHandler>? GettingHotNormalColor; + + /// Determines the current based on the value. + /// + /// if is or + /// if is . If it's + /// overridden can return other values. + /// + public virtual Attribute GetNormalColor () + { + Attribute currAttribute = ColorScheme?.Normal ?? Attribute.Default; + var newAttribute = new Attribute (); + CancelEventArgs args = new (in currAttribute, ref newAttribute); + GettingNormalColor?.Invoke (this, args); + + if (args.Cancel) + { + return args.NewValue; + } + + ColorScheme? cs = ColorScheme ?? new (); + Attribute disabled = new (cs.Disabled.Foreground, cs.Disabled.Background); + + if (Diagnostics.HasFlag (ViewDiagnosticFlags.Hover) && _hovering) + { + disabled = new (disabled.Foreground.GetDarkerColor (), disabled.Background.GetDarkerColor ()); + } + + return Enabled ? GetColor (cs.Normal) : disabled; + } + + /// + /// Raised the Normal Color is being retrieved, from . Cancel the event and set the new + /// attribute in the event args to + /// a different value to change the focus color. + /// + public event EventHandler>? GettingNormalColor; + + /// + /// Sets the Normal attribute if the setting process is not canceled. It triggers an event and checks for + /// cancellation before proceeding. + /// + public void SetNormalAttribute () + { + if (OnSettingNormalAttribute ()) + { + return; + } + + var args = new CancelEventArgs (); + SettingNormalAttribute?.Invoke (this, args); + + if (args.Cancel) + { + return; + } + + if (ColorScheme is { }) + { + SetAttribute (GetNormalColor ()); + } + } + + /// + /// Called when the normal attribute for the View is to be set. This is called before the View is drawn. + /// + /// to stop default behavior. + protected virtual bool OnSettingNormalAttribute () { return false; } + + /// Raised when the normal attribute for the View is to be set. This is raised before the View is drawn. + /// + /// Set to to stop default behavior. + /// + public event EventHandler? SettingNormalAttribute; + + private Attribute GetColor (Attribute inputAttribute) + { + Attribute attr = inputAttribute; + + if (Diagnostics.HasFlag (ViewDiagnosticFlags.Hover) && _hovering) + { + attr = new (attr.Foreground.GetDarkerColor (), attr.Background.GetDarkerColor ()); + } + + return attr; + } +} diff --git a/Terminal.Gui/View/View.Command.cs b/Terminal.Gui/View/View.Command.cs index 8446c9f97..366d7e09f 100644 --- a/Terminal.Gui/View/View.Command.cs +++ b/Terminal.Gui/View/View.Command.cs @@ -1,5 +1,6 @@ #nullable enable using System.ComponentModel; +using System.Dynamic; namespace Terminal.Gui; @@ -115,18 +116,18 @@ public partial class View // Command APIs /// protected bool? RaiseAccepting (ICommandContext? ctx) { - Logging.Trace($"{ctx?.Source?.Title}"); + Logging.Debug ($"{Title} ({ctx?.Source?.Title})"); CommandEventArgs args = new () { Context = ctx }; // Best practice is to invoke the virtual method first. // This allows derived classes to handle the event and potentially cancel it. - Logging.Trace ($"Calling OnAccepting..."); + Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Calling OnAccepting..."); args.Cancel = OnAccepting (args) || args.Cancel; - if (!args.Cancel) + if (!args.Cancel && Accepting is {}) { // If the event is not canceled by the virtual method, raise the event to notify any external subscribers. - Logging.Trace ($"Raising Accepting..."); + Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Raising Accepting..."); Accepting?.Invoke (this, args); } @@ -142,7 +143,9 @@ public partial class View // Command APIs { // TODO: It's a bit of a hack that this uses KeyBinding. There should be an InvokeCommmand that // TODO: is generic? - bool? handled = isDefaultView.InvokeCommand (Command.Accept, ctx); + + Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - InvokeCommand on Default View ({isDefaultView.Title})"); + bool ? handled = isDefaultView.InvokeCommand (Command.Accept, ctx); if (handled == true) { return true; @@ -151,7 +154,7 @@ public partial class View // Command APIs if (SuperView is { }) { - Logging.Trace ($"Invoking Accept on SuperView: {SuperView.Title}..."); + Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Invoking Accept on SuperView ({SuperView.Title}/{SuperView.Id})..."); return SuperView?.InvokeCommand (Command.Accept, ctx); } } @@ -197,6 +200,7 @@ public partial class View // Command APIs /// protected bool? RaiseSelecting (ICommandContext? ctx) { + Logging.Debug ($"{Title} ({ctx?.Source?.Title})"); CommandEventArgs args = new () { Context = ctx }; // Best practice is to invoke the virtual method first. @@ -239,6 +243,7 @@ public partial class View // Command APIs protected bool? RaiseHandlingHotKey () { CommandEventArgs args = new () { Context = new CommandContext () { Command = Command.HotKey } }; + Logging.Debug ($"{Title} ({args.Context?.Source?.Title})"); // Best practice is to invoke the virtual method first. // This allows derived classes to handle the event and potentially cancel it. @@ -421,7 +426,12 @@ public partial class View // Command APIs _commandImplementations.TryGetValue (Command.NotBound, out implementation); } - return implementation! (null); + return implementation! (new CommandContext () + { + Command = command, + Source = this, + Binding = null, + }); } } diff --git a/Terminal.Gui/View/View.Drawing.cs b/Terminal.Gui/View/View.Drawing.cs index 1a198a03d..6929034c2 100644 --- a/Terminal.Gui/View/View.Drawing.cs +++ b/Terminal.Gui/View/View.Drawing.cs @@ -76,25 +76,25 @@ public partial class View // Drawing APIs context ??= new DrawContext (); // TODO: Simplify/optimize SetAttribute system. - DoSetAttribute (); + SetNormalAttribute (); DoClearViewport (context); // ------------------------------------ // Draw the subviews first (order matters: SubViews, Text, Content) if (SubViewNeedsDraw) { - DoSetAttribute (); + SetNormalAttribute (); DoDrawSubViews (context); } // ------------------------------------ // Draw the text - DoSetAttribute (); + SetNormalAttribute (); DoDrawText (context); // ------------------------------------ // Draw the content - DoSetAttribute (); + SetNormalAttribute (); DoDrawContent (context); // ------------------------------------ @@ -268,51 +268,6 @@ public partial class View // Drawing APIs #endregion DrawAdornments - #region SetAttribute - - private void DoSetAttribute () - { - if (OnSettingAttribute ()) - { - return; - } - - var args = new CancelEventArgs (); - SettingAttribute?.Invoke (this, args); - - if (args.Cancel) - { - return; - } - - SetNormalAttribute (); - } - - /// - /// Called when the normal attribute for the View is to be set. This is called before the View is drawn. - /// - /// to stop default behavior. - protected virtual bool OnSettingAttribute () { return false; } - - /// Raised when the normal attribute for the View is to be set. This is raised before the View is drawn. - /// - /// Set to to stop default behavior. - /// - public event EventHandler? SettingAttribute; - - /// - /// Sets the attribute for the View. This is called before the View is drawn. - /// - public void SetNormalAttribute () - { - if (ColorScheme is { }) - { - SetAttribute (GetNormalColor ()); - } - } - - #endregion - #region ClearViewport internal void DoClearViewport (DrawContext? context = null) @@ -673,7 +628,7 @@ public partial class View // Drawing APIs // Get the entire map if (p.Value is { }) { - SetAttribute (p.Value.Value.Attribute ?? ColorScheme!.Normal); + SetAttribute (p.Value.Value.Attribute ?? GetNormalColor ()); Driver.Move (p.Key.X, p.Key.Y); // TODO: #2616 - Support combining sequences that don't normalize diff --git a/Terminal.Gui/View/View.Hierarchy.cs b/Terminal.Gui/View/View.Hierarchy.cs index 82749ffa1..ea017ff33 100644 --- a/Terminal.Gui/View/View.Hierarchy.cs +++ b/Terminal.Gui/View/View.Hierarchy.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -302,7 +303,7 @@ public partial class View // SuperView/SubView hierarchy management (SuperView, public event EventHandler? SubViewRemoved; /// - /// Removes all SubView (children) added via or from this View. + /// Removes all SubViews added via or from this View. /// /// /// @@ -312,12 +313,47 @@ public partial class View // SuperView/SubView hierarchy management (SuperView, /// added. /// /// - public virtual void RemoveAll () + /// + /// A list of removed Views. + /// + public virtual IReadOnlyCollection RemoveAll () { + List removedList = new List (); while (InternalSubViews.Count > 0) { - Remove (InternalSubViews [0]); + View? removed = Remove (InternalSubViews [0]); + if (removed is { }) + { + removedList.Add (removed); + } } + + return removedList.AsReadOnly (); + } + + /// + /// Removes all SubViews of a type added via or from this View. + /// + /// + /// + /// Normally SubViews will be disposed when this View is disposed. Removing a SubView causes ownership of the + /// SubView's + /// lifecycle to be transferred to the caller; the caller must call on any Views that were + /// added. + /// + /// + /// + /// A list of removed Views. + /// + public virtual IReadOnlyCollection RemoveAll () where TView : View + { + List removedList = new List (); + foreach (TView view in InternalSubViews.OfType ().ToList ()) + { + Remove (view); + removedList.Add (view); + } + return removedList.AsReadOnly (); } #pragma warning disable CS0067 // The event is never used diff --git a/Terminal.Gui/View/View.Keyboard.cs b/Terminal.Gui/View/View.Keyboard.cs index fcaf96914..e4f01228f 100644 --- a/Terminal.Gui/View/View.Keyboard.cs +++ b/Terminal.Gui/View/View.Keyboard.cs @@ -282,6 +282,10 @@ public partial class View // Keyboard APIs return false; } + // TODO: We really need an event before recursing into Focused. Without, there's no way for + // TODO: SuperViews to prevent SubViews from seeing certain keys. A use-case for this: + // TODO: - MenuBar needs to prevent MenuItems from seeing QuitKey if the MenuItem is not visible + // If there's a Focused subview, give it a chance (this recurses down the hierarchy) if (Focused?.NewKeyDownEvent (key) == true) { @@ -613,6 +617,11 @@ public partial class View // Keyboard APIs // Process this View if (HotKeyBindings.TryGet (hotKey, out KeyBinding binding)) { + if (binding.Key is null || binding.Key == Key.Empty) + { + binding.Key = hotKey; + } + if (InvokeCommands (binding.Commands, binding) is true) { return true; @@ -657,6 +666,12 @@ public partial class View // Keyboard APIs return null; } + // TODO: Should we set binding.Key = key if it's not set? + if (binding is {} && (binding.Key is null || !binding.Key.IsValid)) + { + binding.Key = key; + } + return InvokeCommands (binding.Commands, binding); } diff --git a/Terminal.Gui/View/View.Mouse.cs b/Terminal.Gui/View/View.Mouse.cs index 7cb77f65b..2e3ba103c 100644 --- a/Terminal.Gui/View/View.Mouse.cs +++ b/Terminal.Gui/View/View.Mouse.cs @@ -1,5 +1,6 @@ #nullable enable using System.ComponentModel; +using static Unix.Terminal.Delegates; namespace Terminal.Gui; @@ -97,21 +98,35 @@ public partial class View // Mouse APIs return args.Cancel; } - ColorScheme? cs = ColorScheme; - - if (cs is null) - { - cs = new (); - } + ColorScheme? cs = _colorScheme; _savedNonHoverColorScheme = cs; - ColorScheme = ColorScheme?.GetHighlightColorScheme (); + _colorScheme = GetHighlightColorScheme (); + SetNeedsDraw (); } return false; } + /// + /// Gets the to use when the view is highlighted. The highlight colorscheme + /// is based on the current , using . + /// + /// The highlight color scheme. + public ColorScheme? GetHighlightColorScheme () + { + ColorScheme? cs = _colorScheme ?? SuperView?.ColorScheme ?? new ColorScheme (); + + return cs with + { + Normal = new (GetNormalColor ().Foreground.GetHighlightColor (), GetNormalColor ().Background), + HotNormal = new (GetHotNormalColor ().Foreground.GetHighlightColor (), GetHotNormalColor ().Background), + Focus = new (GetFocusColor ().Foreground.GetHighlightColor (), GetFocusColor ().Background), + HotFocus = new (GetHotFocusColor ().Foreground.GetHighlightColor (), GetHotFocusColor ().Background) + }; + } + /// /// Called when the mouse moves over the View's and no other non-SubView occludes it. /// will @@ -199,10 +214,12 @@ public partial class View // Mouse APIs var hover = HighlightStyle.None; RaiseHighlight (new (ref copy, ref hover)); - if (_savedNonHoverColorScheme is { }) + // if (_savedNonHoverColorScheme is { }) { - ColorScheme = _savedNonHoverColorScheme; + _colorScheme = _savedNonHoverColorScheme; _savedNonHoverColorScheme = null; + SetNeedsDraw (); + } } } @@ -715,9 +732,14 @@ public partial class View // Mouse APIs if (args.NewValue.HasFlag (HighlightStyle.Pressed) || args.NewValue.HasFlag (HighlightStyle.PressedOutside)) { - if (_savedHighlightColorScheme is null && ColorScheme is { }) + if (_savedHighlightColorScheme is null && _colorScheme is { }) { - _savedHighlightColorScheme ??= ColorScheme; + _savedHighlightColorScheme = _colorScheme; + + if (ColorScheme is null) + { + return false; + } if (CanFocus) { @@ -726,7 +748,7 @@ public partial class View // Mouse APIs // Highlight the foreground focus color Focus = new (ColorScheme.Focus.Foreground.GetHighlightColor (), ColorScheme.Focus.Background.GetHighlightColor ()) }; - ColorScheme = cs; + _colorScheme = cs; } else { @@ -735,7 +757,7 @@ public partial class View // Mouse APIs // Invert Focus color foreground/background. We can do this because we know the view is not going to be focused. Normal = new (ColorScheme.Focus.Background, ColorScheme.Normal.Foreground) }; - ColorScheme = cs; + _colorScheme = cs; } } @@ -746,11 +768,9 @@ public partial class View // Mouse APIs if (args.NewValue == HighlightStyle.None) { // Unhighlight - if (_savedHighlightColorScheme is { }) - { - ColorScheme = _savedHighlightColorScheme; - _savedHighlightColorScheme = null; - } + _colorScheme = _savedHighlightColorScheme; + _savedHighlightColorScheme = null; + SetNeedsDraw (); } return false; @@ -771,7 +791,7 @@ public partial class View // Mouse APIs View? start = Application.Top; // PopoverHost - If visible, start with it instead of Top - if (Application.Popover?.GetActivePopover () is View {Visible: true } visiblePopover && !ignoreTransparent) + if (Application.Popover?.GetActivePopover () is View { Visible: true } visiblePopover && !ignoreTransparent) { start = visiblePopover; @@ -783,7 +803,7 @@ public partial class View // Mouse APIs while (start is { Visible: true } && start.Contains (currentLocation)) { - if (!start.ViewportSettings.HasFlag(ViewportSettings.TransparentMouse)) + if (!start.ViewportSettings.HasFlag (ViewportSettings.TransparentMouse)) { viewsUnderMouse.Add (start); } diff --git a/Terminal.Gui/View/View.Navigation.cs b/Terminal.Gui/View/View.Navigation.cs index 3ef975f6f..ca5d5d50e 100644 --- a/Terminal.Gui/View/View.Navigation.cs +++ b/Terminal.Gui/View/View.Navigation.cs @@ -245,7 +245,7 @@ public partial class View // Focus and cross-view navigation management (TabStop { // If CanFocus is set to false and this view has focus, make it leave focus // Set transversing down so we don't go back up the hierarchy... - SetHasFocusFalse (null, false); + SetHasFocusFalse (null); } if (_canFocus && !HasFocus && Visible && SuperView is { Focused: null }) @@ -319,7 +319,7 @@ public partial class View // Focus and cross-view navigation management (TabStop { //Logging.Trace($"RaiseFocusedChanged: {focused.Title}"); OnFocusedChanged (previousFocused, focused); - FocusedChanged?.Invoke (this, new HasFocusEventArgs (true, true, previousFocused, focused)); + FocusedChanged?.Invoke (this, new (true, true, previousFocused, focused)); } /// @@ -395,10 +395,7 @@ public partial class View // Focus and cross-view navigation management (TabStop /// /// Clears any focus state (e.g. the previously focused subview) from this view. /// - public void ClearFocus () - { - _previouslyFocused = null; - } + public void ClearFocus () { _previouslyFocused = null; } private View? FindDeepestFocusableView (NavigationDirection direction, TabBehavior? behavior) { @@ -523,11 +520,17 @@ public partial class View // Focus and cross-view navigation management (TabStop /// private (bool focusSet, bool cancelled) SetHasFocusTrue (View? currentFocusedView, bool traversingUp = false) { - Debug.Assert (SuperView is null || View.IsInHierarchy (SuperView, this)); + Debug.Assert (SuperView is null || IsInHierarchy (SuperView, this)); // Pre-conditions if (_hasFocus) { + //// See https://github.com/gui-cs/Terminal.Gui/pull/4013#issuecomment-2823934197 + //if (Application.Navigation is { } && (Application.Navigation.GetFocused () == this || Application.Navigation.GetFocused () == MostFocused)) + //{ + // throw new InvalidOperationException (@"Do not SetFocus on a view that is already MostFocused."); + //} + return (false, false); } diff --git a/Terminal.Gui/View/View.Text.cs b/Terminal.Gui/View/View.Text.cs index 971a8ac83..610ad0859 100644 --- a/Terminal.Gui/View/View.Text.cs +++ b/Terminal.Gui/View/View.Text.cs @@ -68,12 +68,6 @@ public partial class View // Text Property APIs UpdateTextFormatterText (); SetNeedsLayout (); -#if DEBUG - if (_text is { } && string.IsNullOrEmpty (Id)) - { - Id = _text; - } -#endif OnTextChanged (); } } diff --git a/Terminal.Gui/View/View.cs b/Terminal.Gui/View/View.cs index 21885b6fd..0c78fd873 100644 --- a/Terminal.Gui/View/View.cs +++ b/Terminal.Gui/View/View.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; @@ -31,28 +32,15 @@ public partial class View : IDisposable, ISupportInitializeNotification { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Disposing?.Invoke (this, EventArgs.Empty); + Dispose (true); GC.SuppressFinalize (this); + #if DEBUG_IDISPOSABLE - if (DebugIDisposable) - { - WasDisposed = true; - - foreach (View? instance in Instances.Where ( - x => - { - if (x is { }) - { - return x.WasDisposed; - } - - return false; - }) - .ToList ()) - { - Instances.Remove (instance); - } - } + WasDisposed = true; + // Safely remove any disposed views from the Instances list + List itemsToKeep = Instances.Where (view => !view.WasDisposed).ToList (); + Instances = new ConcurrentBag (itemsToKeep); #endif } @@ -74,31 +62,34 @@ public partial class View : IDisposable, ISupportInitializeNotification /// protected virtual void Dispose (bool disposing) { - LineCanvas.Dispose (); - - DisposeMouse (); - DisposeKeyboard (); - DisposeAdornments (); - DisposeScrollBars (); - - for (int i = InternalSubViews.Count - 1; i >= 0; i--) + if (disposing) { - View subview = InternalSubViews [i]; - Remove (subview); - subview.Dispose (); - } + LineCanvas.Dispose (); - if (!_disposedValue) - { - if (disposing) + DisposeMouse (); + DisposeKeyboard (); + DisposeAdornments (); + DisposeScrollBars (); + + for (int i = InternalSubViews.Count - 1; i >= 0; i--) { - // TODO: dispose managed state (managed objects) + View subview = InternalSubViews [i]; + Remove (subview); + subview.Dispose (); } - _disposedValue = true; - } + if (!_disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } - Debug.Assert (InternalSubViews.Count == 0); + _disposedValue = true; + } + + Debug.Assert (InternalSubViews.Count == 0); + } } #region Constructors and Initialization @@ -128,10 +119,7 @@ public partial class View : IDisposable, ISupportInitializeNotification public View () { #if DEBUG_IDISPOSABLE - if (DebugIDisposable) - { - Instances.Add (this); - } + Instances.Add (this); #endif SetupAdornments (); @@ -338,7 +326,7 @@ public partial class View : IDisposable, ISupportInitializeNotification if (!_visible) { // BUGBUG: Ideally we'd reset _previouslyFocused to the first focusable subview - _previouslyFocused = SubViews.FirstOrDefault(v => v.CanFocus); + _previouslyFocused = SubViews.FirstOrDefault (v => v.CanFocus); if (HasFocus) { HasFocus = false; @@ -445,18 +433,12 @@ public partial class View : IDisposable, ISupportInitializeNotification { get { -#if DEBUG_IDISPOSABLE - if (DebugIDisposable && WasDisposed) - { - throw new ObjectDisposedException (GetType ().FullName); - } -#endif return _title; } set { #if DEBUG_IDISPOSABLE - if (DebugIDisposable && WasDisposed) + if (EnableDebugIDisposableAsserts && WasDisposed) { throw new ObjectDisposedException (GetType ().FullName); } @@ -475,12 +457,6 @@ public partial class View : IDisposable, ISupportInitializeNotification SetTitleTextFormatterSize (); SetHotKeyFromTitle (); SetNeedsDraw (); -#if DEBUG - if (string.IsNullOrEmpty (Id)) - { - Id = _title; - } -#endif // DEBUG OnTitleChanged (); } } @@ -527,17 +503,34 @@ public partial class View : IDisposable, ISupportInitializeNotification #if DEBUG_IDISPOSABLE /// - /// Set to false to disable the debug IDisposable feature. + /// Gets or sets whether failure to appropriately call Dispose() on a View will result in an Assert. + /// The default is . + /// Note, this is a static property and will affect all Views. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. /// - public static bool DebugIDisposable { get; set; } = false; + public static bool EnableDebugIDisposableAsserts { get; set; } = true; - /// For debug purposes to verify objects are being disposed properly - public bool WasDisposed { get; set; } + /// + /// Gets whether was called on this view or not. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public bool WasDisposed { get; private set; } - /// For debug purposes to verify objects are being disposed properly - public int DisposedCount { get; set; } = 0; + /// + /// Gets the number of times was called on this view. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public int DisposedCount { get; private set; } = 0; - /// For debug purposes - public static List Instances { get; set; } = []; + /// + /// Gets the list of Views that have been created and not yet disposed. + /// Note, this is a static property and will affect all Views. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public static ConcurrentBag Instances { get; private set; } = []; #endif } diff --git a/Terminal.Gui/Views/Bar.cs b/Terminal.Gui/Views/Bar.cs index 00cdaf575..246d9faa6 100644 --- a/Terminal.Gui/Views/Bar.cs +++ b/Terminal.Gui/Views/Bar.cs @@ -74,24 +74,6 @@ public class Bar : View, IOrientation, IDesignable } } - /// - public override void EndInit () - { - base.EndInit (); - ColorScheme = Colors.ColorSchemes ["Menu"]; - } - - /// - public override void SetBorderStyle (LineStyle lineStyle) - { - if (Border is { }) - { - // The default changes the thickness. We don't want that. We just set the style. - Border.LineStyle = lineStyle; - } - //base.SetBorderStyle(lineStyle); - } - #region IOrientation members /// @@ -216,7 +198,7 @@ public class Bar : View, IOrientation, IDesignable { View barItem = SubViews.ElementAt (index); - barItem.ColorScheme = ColorScheme; + //barItem.ColorScheme = ColorScheme; barItem.X = Pos.Align (Alignment.Start, AlignmentModes); barItem.Y = 0; //Pos.Center (); @@ -235,7 +217,7 @@ public class Bar : View, IOrientation, IDesignable var minKeyWidth = 0; - List shortcuts = SubViews.Where (s => s is Shortcut && s.Visible).Cast ().ToList (); + List shortcuts = SubViews.OfType ().Where (s => s.Visible).ToList (); foreach (Shortcut shortcut in shortcuts) { @@ -250,7 +232,7 @@ public class Bar : View, IOrientation, IDesignable View barItem = SubViews.ElementAt (index); - barItem.ColorScheme = ColorScheme; + // barItem.ColorScheme = ColorScheme; if (!barItem.Visible) { diff --git a/Terminal.Gui/Views/CheckBox.cs b/Terminal.Gui/Views/CheckBox.cs index cec31d4f9..d42865b69 100644 --- a/Terminal.Gui/Views/CheckBox.cs +++ b/Terminal.Gui/Views/CheckBox.cs @@ -1,7 +1,12 @@ #nullable enable namespace Terminal.Gui; -/// Shows a check box that can be cycled between two or three states. +/// Shows a checkbox that can be cycled between two or three states. +/// +/// +/// is used to display radio button style glyphs (●) instead of checkbox style glyphs (☑). +/// +/// public class CheckBox : View { /// @@ -250,22 +255,23 @@ public class CheckBox : View { base.UpdateTextFormatterText (); + Rune glyph = RadioStyle ? GetRadioGlyph () : GetCheckGlyph (); switch (TextAlignment) { case Alignment.Start: case Alignment.Center: case Alignment.Fill: - TextFormatter.Text = $"{GetCheckedGlyph ()} {Text}"; + TextFormatter.Text = $"{glyph} {Text}"; break; case Alignment.End: - TextFormatter.Text = $"{Text} {GetCheckedGlyph ()}"; + TextFormatter.Text = $"{Text} {glyph}"; break; } } - private Rune GetCheckedGlyph () + private Rune GetCheckGlyph () { return CheckedState switch { @@ -275,4 +281,21 @@ public class CheckBox : View _ => throw new ArgumentOutOfRangeException () }; } + + /// + /// If , the will display radio button style glyphs (●) instead of + /// checkbox style glyphs (☑). + /// + public bool RadioStyle { get; set; } + + private Rune GetRadioGlyph () + { + return CheckedState switch + { + CheckState.Checked => Glyphs.Selected, + CheckState.UnChecked => Glyphs.UnSelected, + CheckState.None => Glyphs.Dot, + _ => throw new ArgumentOutOfRangeException () + }; + } } diff --git a/Terminal.Gui/Views/Dialog.cs b/Terminal.Gui/Views/Dialog.cs index a70b597fd..4895de58f 100644 --- a/Terminal.Gui/Views/Dialog.cs +++ b/Terminal.Gui/Views/Dialog.cs @@ -107,7 +107,7 @@ public class Dialog : Window get { #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable && WasDisposed) + if (View.EnableDebugIDisposableAsserts && WasDisposed) { throw new ObjectDisposedException (GetType ().FullName); } @@ -117,7 +117,7 @@ public class Dialog : Window set { #if DEBUG_IDISPOSABLE - if (View.DebugIDisposable && WasDisposed) + if (View.EnableDebugIDisposableAsserts && WasDisposed) { throw new ObjectDisposedException (GetType ().FullName); } diff --git a/Terminal.Gui/Views/FlagSelector.cs b/Terminal.Gui/Views/FlagSelector.cs index 97316f789..0923f155b 100644 --- a/Terminal.Gui/Views/FlagSelector.cs +++ b/Terminal.Gui/Views/FlagSelector.cs @@ -1,12 +1,11 @@ #nullable enable namespace Terminal.Gui; - /// /// Provides a user interface for displaying and selecting flags. /// Flags can be set from a dictionary or directly from an enum type. /// -public class FlagSelector : View, IDesignable, IOrientation +public class FlagSelector : View, IOrientation, IDesignable { /// /// Initializes a new instance of the class. @@ -25,17 +24,17 @@ public class FlagSelector : View, IDesignable, IOrientation // Accept (Enter key or DoubleClick) - Raise Accept event - DO NOT advance state AddCommand (Command.Accept, HandleAcceptCommand); - CreateSubViews (); + CreateCheckBoxes (); } private bool? HandleAcceptCommand (ICommandContext? ctx) { return RaiseAccepting (ctx); } - private uint _value; + private uint? _value; /// /// Gets or sets the value of the selected flags. /// - public uint Value + public uint? Value { get => _value; set @@ -47,19 +46,19 @@ public class FlagSelector : View, IDesignable, IOrientation _value = value; - if (_value == 0) + if (_value is null) { + UncheckNone (); UncheckAll (); } else { - UncheckNone (); UpdateChecked (); } if (ValueEdit is { }) { - ValueEdit.Text = value.ToString (); + ValueEdit.Text = _value.ToString (); } RaiseValueChanged (); @@ -69,7 +68,10 @@ public class FlagSelector : View, IDesignable, IOrientation private void RaiseValueChanged () { OnValueChanged (); - ValueChanged?.Invoke (this, new (Value)); + if (Value.HasValue) + { + ValueChanged?.Invoke (this, new EventArgs (Value.Value)); + } } /// @@ -99,7 +101,7 @@ public class FlagSelector : View, IDesignable, IOrientation _styles = value; - CreateSubViews (); + CreateCheckBoxes (); } } @@ -107,12 +109,14 @@ public class FlagSelector : View, IDesignable, IOrientation /// Set the flags and flag names. /// /// - public void SetFlags (IReadOnlyDictionary flags) + public virtual void SetFlags (IReadOnlyDictionary flags) { Flags = flags; - CreateSubViews (); + CreateCheckBoxes (); + UpdateChecked (); } + /// /// Set the flags and flag names from an enum type. /// @@ -167,27 +171,66 @@ public class FlagSelector : View, IDesignable, IOrientation SetFlags (flagsDictionary); } + private IReadOnlyDictionary? _flags; + /// - /// Gets the flags. + /// Gets the flag values and names. /// - public IReadOnlyDictionary? Flags { get; internal set; } + public IReadOnlyDictionary? Flags + { + get => _flags; + internal set + { + _flags = value; + + if (_value is null) + { + Value = Convert.ToUInt16 (_flags?.Keys.ElementAt (0)); + } + } + } private TextField? ValueEdit { get; set; } - private void CreateSubViews () + private bool _assignHotKeysToCheckBoxes; + + /// + /// If the CheckBoxes will each be automatically assigned a hotkey. + /// will be used to ensure unique keys are assigned. Set + /// before setting with any hotkeys that may conflict with other Views. + /// + public bool AssignHotKeysToCheckBoxes + { + get => _assignHotKeysToCheckBoxes; + set + { + if (_assignHotKeysToCheckBoxes == value) + { + return; + } + _assignHotKeysToCheckBoxes = value; + CreateCheckBoxes (); + UpdateChecked(); + } + } + + /// + /// Gets the list of hotkeys already used by the CheckBoxes or that should not be used if + /// + /// is enabled. + /// + public List UsedHotKeys { get; } = []; + + private void CreateCheckBoxes () { if (Flags is null) { return; } - View [] subviews = SubViews.ToArray (); - - RemoveAll (); - - foreach (View v in subviews) + foreach (CheckBox cb in RemoveAll ()) { - v.Dispose (); + cb.Dispose (); } if (Styles.HasFlag (FlagSelectorStyles.ShowNone) && !Flags.ContainsKey (0)) @@ -213,7 +256,7 @@ public class FlagSelector : View, IDesignable, IOrientation CanFocus = false, Text = Value.ToString (), Width = 5, - ReadOnly = true + ReadOnly = true, }; Add (ValueEdit); @@ -223,48 +266,146 @@ public class FlagSelector : View, IDesignable, IOrientation return; - CheckBox CreateCheckBox (string name, uint flag) + + } + + /// + /// + /// + /// + /// + /// + protected virtual CheckBox CreateCheckBox (string name, uint flag) + { + string nameWithHotKey = name; + if (AssignHotKeysToCheckBoxes) { - var checkbox = new CheckBox + // Find the first char in label that is [a-z], [A-Z], or [0-9] + for (var i = 0; i < name.Length; i++) { - CanFocus = false, - Title = name, - Id = name, - Data = flag, - HighlightStyle = HighlightStyle - }; + char c = char.ToLowerInvariant (name [i]); + if (UsedHotKeys.Contains (new (c)) || !char.IsAsciiLetterOrDigit (c)) + { + continue; + } - checkbox.Selecting += (sender, args) => { RaiseSelecting (args.Context); }; + if (char.IsAsciiLetterOrDigit (c)) + { + char? hotChar = c; + nameWithHotKey = name.Insert (i, HotKeySpecifier.ToString ()); + UsedHotKeys.Add (new (hotChar)); - checkbox.CheckedStateChanged += (sender, args) => + break; + } + } + } + + var checkbox = new CheckBox + { + CanFocus = true, + Title = nameWithHotKey, + Id = name, + Data = flag, + HighlightStyle = HighlightStyle.Hover + }; + + checkbox.GettingNormalColor += (_, e) => + { + if (SuperView is { HasFocus: true }) + { + e.Cancel = true; + + if (!HasFocus) + { + e.NewValue = GetFocusColor (); + } + else + { + // If _colorScheme was set, it's because of Hover + if (checkbox._colorScheme is { }) + { + e.NewValue = checkbox._colorScheme.Normal; + } + else + { + e.NewValue = GetNormalColor (); + } + } + } + }; + + checkbox.GettingHotNormalColor += (_, e) => + { + if (SuperView is { HasFocus: true }) + { + e.Cancel = true; + if (!HasFocus) + { + e.NewValue = GetHotFocusColor (); + } + else + { + e.NewValue = GetHotNormalColor (); + } + } + }; + + //checkbox.GettingFocusColor += (_, e) => + // { + // if (SuperView is { HasFocus: true }) + // { + // e.Cancel = true; + // if (!HasFocus) + // { + // e.NewValue = GetNormalColor (); + // } + // else + // { + // e.NewValue = GetFocusColor (); + // } + // } + // }; + + checkbox.Selecting += (sender, args) => + { + if (RaiseSelecting (args.Context) is true) + { + args.Cancel = true; + + return; + }; + + if (RaiseAccepting (args.Context) is true) + { + args.Cancel = true; + } + }; + + checkbox.CheckedStateChanged += (sender, args) => + { + uint? newValue = Value; + + if (checkbox.CheckedState == CheckState.Checked) { - uint newValue = Value; - - if (checkbox.CheckedState == CheckState.Checked) + if (flag == default!) { - if ((uint)checkbox.Data == 0) - { - newValue = 0; - } - else - { - newValue |= flag; - } + newValue = 0; } else { - newValue &= ~flag; + newValue = newValue | flag; } + } + else + { + newValue = newValue & ~flag; + } - Value = newValue; + Value = newValue; + }; - //UpdateChecked(); - }; - - return checkbox; - } + return checkbox; } - private void SetLayout () { foreach (View sv in SubViews) @@ -285,7 +426,7 @@ public class FlagSelector : View, IDesignable, IOrientation private void UncheckAll () { - foreach (CheckBox cb in SubViews.Where (sv => sv is CheckBox cb && cb.Title != "None").Cast ()) + foreach (CheckBox cb in SubViews.OfType ().Where (sv => (uint)(sv.Data ?? default!) != default!)) { cb.CheckedState = CheckState.UnChecked; } @@ -293,7 +434,7 @@ public class FlagSelector : View, IDesignable, IOrientation private void UncheckNone () { - foreach (CheckBox cb in SubViews.Where (sv => sv is CheckBox { Title: "None" }).Cast ()) + foreach (CheckBox cb in SubViews.OfType ().Where (sv => sv.Title != "None")) { cb.CheckedState = CheckState.UnChecked; } @@ -301,7 +442,7 @@ public class FlagSelector : View, IDesignable, IOrientation private void UpdateChecked () { - foreach (CheckBox cb in SubViews.Where (sv => sv is CheckBox { }).Cast ()) + foreach (CheckBox cb in SubViews.OfType ()) { var flag = (uint)(cb.Data ?? throw new InvalidOperationException ("ComboBox.Data must be set")); @@ -317,8 +458,6 @@ public class FlagSelector : View, IDesignable, IOrientation } } - /// - protected override void OnSubViewAdded (View view) { } #region IOrientation @@ -342,8 +481,6 @@ public class FlagSelector : View, IDesignable, IOrientation public event EventHandler>? OrientationChanged; #pragma warning restore CS0067 // The event is never used -#pragma warning restore CS0067 - /// Called when has changed. /// public void OnOrientationChanged (Orientation newOrientation) { SetLayout (); } @@ -353,12 +490,14 @@ public class FlagSelector : View, IDesignable, IOrientation /// public bool EnableForDesign () { + Styles = FlagSelectorStyles.All; SetFlags ( f => f switch { - FlagSelectorStyles.ShowNone => "Show _None Value", - FlagSelectorStyles.ShowValueEdit => "Show _Value Editor", - FlagSelectorStyles.All => "Show _All Flags Selector", + FlagSelectorStyles.None => "_No Style", + FlagSelectorStyles.ShowNone => "_Show None Value Style", + FlagSelectorStyles.ShowValueEdit => "Show _Value Editor Style", + FlagSelectorStyles.All => "_All Styles", _ => f.ToString () }); diff --git a/Terminal.Gui/Views/FlagSelectorTEnum.cs b/Terminal.Gui/Views/FlagSelectorTEnum.cs new file mode 100644 index 000000000..cec0342c4 --- /dev/null +++ b/Terminal.Gui/Views/FlagSelectorTEnum.cs @@ -0,0 +1,99 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Provides a user interface for displaying and selecting flags. +/// Flags can be set from a dictionary or directly from an enum type. +/// +public sealed class FlagSelector : FlagSelector where TEnum : struct, Enum +{ + /// + /// Initializes a new instance of the class. + /// + public FlagSelector () + { + SetFlags (); + } + + /// + /// Gets or sets the value of the selected flags. + /// + public new TEnum? Value + { + get => base.Value.HasValue ? (TEnum)Enum.ToObject (typeof (TEnum), base.Value.Value) : (TEnum?)null; + set => base.Value = value.HasValue ? Convert.ToUInt32 (value.Value) : (uint?)null; + } + + /// + /// Set the display names for the flags. + /// + /// A function that converts enum values to display names + /// + /// This method allows changing the display names of the flags while keeping the flag values hard-defined by the enum type. + /// + /// + /// + /// // Use enum values with custom display names + /// var flagSelector = new FlagSelector<FlagSelectorStyles>(); + /// flagSelector.SetFlagNames(f => f switch { + /// FlagSelectorStyles.ShowNone => "Show None Value", + /// FlagSelectorStyles.ShowValueEdit => "Show Value Editor", + /// FlagSelectorStyles.All => "Everything", + /// _ => f.ToString() + /// }); + /// + /// + public void SetFlagNames (Func nameSelector) + { + Dictionary flagsDictionary = Enum.GetValues () + .ToDictionary (f => Convert.ToUInt32 (f), nameSelector); + base.SetFlags (flagsDictionary); + } + + private void SetFlags () + { + Dictionary flagsDictionary = Enum.GetValues () + .ToDictionary (f => Convert.ToUInt32 (f), f => f.ToString ()); + base.SetFlags (flagsDictionary); + } + + /// + /// Prevents calling the base SetFlags method with arbitrary flag values. + /// + /// + public override void SetFlags (IReadOnlyDictionary flags) + { + throw new InvalidOperationException ("Setting flag values directly is not allowed. Use SetFlagNames to change display names."); + } + + /// + protected override CheckBox CreateCheckBox (string name, uint flag) + { + var checkbox = base.CreateCheckBox (name, flag); + checkbox.CheckedStateChanged += (sender, args) => + { + TEnum? newValue = Value; + + if (checkbox.CheckedState == CheckState.Checked) + { + if (flag == default!) + { + newValue = new TEnum (); + } + else + { + newValue = (TEnum)Enum.ToObject (typeof (TEnum), Convert.ToUInt32 (newValue) | flag); + } + } + else + { + newValue = (TEnum)Enum.ToObject (typeof (TEnum), Convert.ToUInt32 (newValue) & ~flag); + } + + Value = newValue; + }; + + return checkbox; + } + +} diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index 772a9775c..f81f16270 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -807,34 +807,41 @@ public class ListView : View, IDesignable } /// - protected override bool OnKeyDown (Key a) + protected override bool OnKeyDown (Key key) { // If marking is enabled and the user presses the space key don't let CollectionNavigator // at it if (AllowsMarking) { - var keys = KeyBindings.GetAllFromCommands (Command.Select); + IEnumerable keys = KeyBindings.GetAllFromCommands (Command.Select); - if (keys.Contains (a)) + if (keys.Contains (key)) { return false; } keys = KeyBindings.GetAllFromCommands ([Command.Select, Command.Down]); - if (keys.Contains (a)) + if (keys.Contains (key)) { return false; } } - // Enable user to find & select an item by typing text - if (CollectionNavigatorBase.IsCompatibleKey (a)) + // If the key was bound to a command, invoke the command. This enables overriding the default handling. + // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939 + if (KeyBindings.TryGet (key, out _)) { - int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)a); + return false; + } - if (newItem is int && newItem != -1) + // Enable user to find & select an item by typing text + if (CollectionNavigatorBase.IsCompatibleKey (key)) + { + int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); + + if (newItem is { } && newItem != -1) { SelectedItem = (int)newItem; EnsureSelectedItemVisible (); diff --git a/Terminal.Gui/Views/Menu/MenuBarItemv2.cs b/Terminal.Gui/Views/Menu/MenuBarItemv2.cs index 6df8e0759..325a3d3d3 100644 --- a/Terminal.Gui/Views/Menu/MenuBarItemv2.cs +++ b/Terminal.Gui/Views/Menu/MenuBarItemv2.cs @@ -4,7 +4,7 @@ namespace Terminal.Gui; /// /// A -derived object to be used as items in a . -/// MenuBarItems have a title, a hotkey, and an action to execute on activation. +/// MenuBarItems hold a instead of a . /// public class MenuBarItemv2 : MenuItemv2 { @@ -74,7 +74,7 @@ public class MenuBarItemv2 : MenuItemv2 null, Command.NotBound, commandText, - new (menuItems)) + new (menuItems) { Title = $"PopoverMenu for {commandText}" }) { } /// @@ -87,10 +87,81 @@ public class MenuBarItemv2 : MenuItemv2 set => throw new InvalidOperationException ("MenuBarItem does not support SubMenu. Use PopoverMenu instead."); } + private PopoverMenu? _popoverMenu; + /// /// The Popover Menu that will be displayed when this item is selected. /// - public PopoverMenu? PopoverMenu { get; set; } + public PopoverMenu? PopoverMenu + { + get => _popoverMenu; + set + { + if (_popoverMenu == value) + { + return; + } + + if (_popoverMenu is { }) + { + _popoverMenu.VisibleChanged -= OnPopoverVisibleChanged; + _popoverMenu.Accepted -= OnPopoverMenuOnAccepted; + } + + _popoverMenu = value; + + if (_popoverMenu is { }) + { + PopoverMenuOpen = _popoverMenu.Visible; + _popoverMenu.VisibleChanged += OnPopoverVisibleChanged; + _popoverMenu.Accepted += OnPopoverMenuOnAccepted; + } + + return; + + void OnPopoverVisibleChanged (object? sender, EventArgs args) + { + Logging.Debug ($"OnPopoverVisibleChanged - {Title} - Visible = {_popoverMenu?.Visible} "); + PopoverMenuOpen = _popoverMenu?.Visible ?? false; + } + + void OnPopoverMenuOnAccepted (object? sender, CommandEventArgs args) + { + Logging.Debug ($"OnPopoverMenuOnAccepted - {Title} - {args.Context?.Source?.Title} - {args.Context?.Command}"); + RaiseAccepted (args.Context); + } + } + } + + private bool _popoverMenuOpen; + + /// + /// Gets or sets whether the MenuBarItem is active. This is used to determine if the MenuBarItem should be + /// + public bool PopoverMenuOpen + { + get => _popoverMenuOpen; + set + { + if (_popoverMenuOpen == value) + { + return; + } + _popoverMenuOpen = value; + + RaisePopoverMenuOpenChanged(); + } + } + + public void RaisePopoverMenuOpenChanged () + { + OnPopoverMenuOpenChanged(); + PopoverMenuOpenChanged?.Invoke (this, new EventArgs (PopoverMenuOpen)); + } + + protected virtual void OnPopoverMenuOpenChanged () {} + + public event EventHandler>? PopoverMenuOpenChanged; /// protected override bool OnKeyDownNotHandled (Key key) @@ -112,6 +183,12 @@ public class MenuBarItemv2 : MenuItemv2 return false; } + /// + protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) + { + Logging.Debug ($"CanFocus = {CanFocus}, HasFocus = {HasFocus}"); + } + /// protected override void Dispose (bool disposing) { diff --git a/Terminal.Gui/Views/Menu/MenuBarv2.cs b/Terminal.Gui/Views/Menu/MenuBarv2.cs index 7850d673c..7dcc40c01 100644 --- a/Terminal.Gui/Views/Menu/MenuBarv2.cs +++ b/Terminal.Gui/Views/Menu/MenuBarv2.cs @@ -24,27 +24,38 @@ public class MenuBarv2 : Menuv2, IDesignable TabStop = TabBehavior.TabGroup; Y = 0; Width = Dim.Fill (); + Height = Dim.Auto (); Orientation = Orientation.Horizontal; Key = DefaultKey; - AddCommand (Command.HotKey, - () => - { - if (HideActiveItem ()) - { - return true; - } - if (SubViews.FirstOrDefault (sv => sv is MenuBarItemv2 { PopoverMenu: { } }) is MenuBarItemv2 { } first) - { - _active = true; - ShowPopover (first); + AddCommand ( + Command.HotKey, + () => + { + Logging.Debug ($"{Title} - Command.HotKey"); + if (RaiseHandlingHotKey () is true) + { + return true; + } - return true; - } + if (HideActiveItem ()) + { + return true; + } - return false; - }); + if (SubViews.OfType ().FirstOrDefault (mbi => mbi.PopoverMenu is { }) is { } first) + { + Active = true; + ShowItem (first); + + return true; + } + + return false; + }); + + // If we're not focused, Key activates/deactivates HotKeyBindings.Add (Key, Command.HotKey); KeyBindings.Add (Key, Command.Quit); @@ -54,6 +65,7 @@ public class MenuBarv2 : Menuv2, IDesignable Command.Quit, ctx => { + Logging.Debug ($"{Title} - Command.Quit"); if (HideActiveItem ()) { return true; @@ -62,12 +74,12 @@ public class MenuBarv2 : Menuv2, IDesignable if (CanFocus) { CanFocus = false; - _active = false; + Active = false; return true; } - return false;//RaiseAccepted (ctx); + return false; //RaiseAccepted (ctx); }); AddCommand (Command.Right, MoveRight); @@ -76,6 +88,11 @@ public class MenuBarv2 : Menuv2, IDesignable AddCommand (Command.Left, MoveLeft); KeyBindings.Add (Key.CursorLeft, Command.Left); + BorderStyle = DefaultBorderStyle; + + Applied += OnConfigurationManagerApplied; + SuperViewChanged += OnSuperViewChanged; + return; bool? MoveLeft (ICommandContext? ctx) { return AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop); } @@ -83,6 +100,34 @@ public class MenuBarv2 : Menuv2, IDesignable bool? MoveRight (ICommandContext? ctx) { return AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); } } + private void OnSuperViewChanged (object? sender, SuperViewChangedEventArgs e) + { + if (SuperView is null) + { + // BUGBUG: This is a hack for avoiding a race condition in ConfigurationManager.Apply + // BUGBUG: For some reason in some unit tests, when Top is disposed, MenuBar.Dispose does not get called. + // BUGBUG: Yet, the MenuBar does get Removed from Top (and it's SuperView set to null). + // BUGBUG: Related: https://github.com/gui-cs/Terminal.Gui/issues/4021 + Applied -= OnConfigurationManagerApplied; + } + } + + private void OnConfigurationManagerApplied (object? sender, ConfigurationManagerEventArgs e) { BorderStyle = DefaultBorderStyle; } + + /// + protected override bool OnBorderStyleChanged () + { + //HideActiveItem (); + + return base.OnBorderStyleChanged (); + } + + /// + /// Gets or sets the default Border Style for the MenuBar. The default is . + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope))] + public new static LineStyle DefaultBorderStyle { get; set; } = LineStyle.None; + private Key _key = DefaultKey; /// Specifies the key that will activate the context menu. @@ -110,10 +155,12 @@ public class MenuBarv2 : Menuv2, IDesignable set { RemoveAll (); + if (value is null) { return; } + foreach (MenuBarItemv2 mbi in value) { Add (mbi); @@ -121,6 +168,52 @@ public class MenuBarv2 : Menuv2, IDesignable } } + /// + protected override void OnSubViewAdded (View view) + { + base.OnSubViewAdded (view); + + if (view is MenuBarItemv2 mbi) + { + mbi.Accepted += OnMenuBarItemAccepted; + mbi.PopoverMenuOpenChanged += OnMenuBarItemPopoverMenuOpenChanged; + } + } + + /// + protected override void OnSubViewRemoved (View view) + { + base.OnSubViewRemoved (view); + if (view is MenuBarItemv2 mbi) + { + mbi.Accepted -= OnMenuBarItemAccepted; + mbi.PopoverMenuOpenChanged -= OnMenuBarItemPopoverMenuOpenChanged; + } + } + + private void OnMenuBarItemPopoverMenuOpenChanged (object? sender, EventArgs e) + { + if (sender is MenuBarItemv2 mbi) + { + if (e.CurrentValue) + { + Active = true; + } + else + { + + + } + } + } + + private void OnMenuBarItemAccepted (object? sender, CommandEventArgs e) + { + Logging.Debug ($"{Title} ({e.Context?.Source?.Title}) Command: {e.Context?.Command}"); + + RaiseAccepted (e.Context); + } + /// Raised when is changed. public event EventHandler? KeyChanged; @@ -132,61 +225,85 @@ public class MenuBarv2 : Menuv2, IDesignable /// Gets whether any of the menu bar items have a visible . /// /// - public bool IsOpen () - { - return SubViews.Count (sv => sv is MenuBarItemv2 { PopoverMenu: { Visible: true } }) > 0; - } + public bool IsOpen () { return SubViews.OfType().Count (sv => sv is { PopoverMenuOpen: true }) > 0; } private bool _active; /// - /// Returns a value indicating whether the menu bar is active or not. When active, moving the mouse - /// over a menu bar item will activate it. + /// Gets or sets whether the menu bar is active or not. When active, the MenuBar can focus and moving the mouse + /// over a MenuBarItem will switch focus to that item. Use to determine if a PopoverMenu of + /// a MenuBarItem is open. /// /// - public bool IsActive () + public bool Active { - return _active; + get => _active; + internal set + { + if (_active == value) + { + return; + } + + _active = value; + Logging.Debug ($"Active set to {_active} - CanFocus: {CanFocus}, HasFocus: {HasFocus}"); + + if (!_active) + { + // Hide open Popovers + HideActiveItem (); + } + + CanFocus = value; + Logging.Debug ($"Set CanFocus: {CanFocus}, HasFocus: {HasFocus}"); + + } } - /// + /// protected override bool OnMouseEnter (CancelEventArgs eventArgs) { // If the MenuBar does not have focus and the mouse enters: Enable CanFocus // But do NOT show a Popover unless the user clicks or presses a hotkey + Logging.Debug ($"CanFocus = {CanFocus}, HasFocus = {HasFocus}"); if (!HasFocus) { - CanFocus = true; + Active = true; } + return base.OnMouseEnter (eventArgs); } - /// + /// protected override void OnMouseLeave () { + Logging.Debug ($"CanFocus = {CanFocus}, HasFocus = {HasFocus}"); if (!IsOpen ()) { - CanFocus = false; + Active = false; } + base.OnMouseLeave (); } - /// + /// protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) { + Logging.Debug ($"CanFocus = {CanFocus}, HasFocus = {HasFocus}"); if (!newHasFocus) { - _active = false; - CanFocus = false; + Active = false; } } /// protected override void OnSelectedMenuItemChanged (MenuItemv2? selected) { - if (selected is MenuBarItemv2 { PopoverMenu.Visible: false } selectedMenuBarItem) + Logging.Debug ($"{Title} ({selected?.Title}) - IsOpen: {IsOpen ()}"); + + if (IsOpen () && selected is MenuBarItemv2 { PopoverMenuOpen: false } selectedMenuBarItem) { - ShowPopover (selectedMenuBarItem); + ShowItem (selectedMenuBarItem); } } @@ -211,45 +328,65 @@ public class MenuBarv2 : Menuv2, IDesignable /// protected override bool OnAccepting (CommandEventArgs args) { - Logging.Trace ($"{args.Context?.Source?.Title}"); + Logging.Debug ($"{Title} ({args.Context?.Source?.Title})"); - if (Visible && args.Context?.Source is MenuBarItemv2 { PopoverMenu.Visible: false } sourceMenuBarItem) + // TODO: Ensure sourceMenuBar is actually one of our bar items + if (Visible && Enabled && args.Context?.Source is MenuBarItemv2 { PopoverMenuOpen: false } sourceMenuBarItem) { - _active = true; - if (!CanFocus) { - // Enabling CanFocus will cause focus to change, which will cause OnSelectedMenuItem to change - // This will call ShowPopover - CanFocus = true; - sourceMenuBarItem.SetFocus (); + Debug.Assert (!Active); + + // We are not Active; change that + Active = true; + + ShowItem(sourceMenuBarItem); + + if (!sourceMenuBarItem.HasFocus) + { + sourceMenuBarItem.SetFocus (); + } } else { - ShowPopover (sourceMenuBarItem); + Debug.Assert (Active); + ShowItem (sourceMenuBarItem); } return true; } - return base.OnAccepting (args); + return false; + } + + /// + protected override void OnAccepted (CommandEventArgs args) + { + Logging.Debug ($"{Title} ({args.Context?.Source?.Title}) Command: {args.Context?.Command}"); + base.OnAccepted (args); + + if (SubViews.OfType ().Contains (args.Context?.Source)) + { + return; + } + + Active = false; } /// /// Shows the specified popover, but only if the menu bar is active. /// /// - private void ShowPopover (MenuBarItemv2? menuBarItem) + private void ShowItem (MenuBarItemv2? menuBarItem) { - Logging.Trace ($"{menuBarItem?.Id}"); + Logging.Debug ($"{Title} - {menuBarItem?.Id}"); - if (!_active || !Visible) + if (!Active || !Visible) { + Logging.Debug ($"{Title} - {menuBarItem?.Id} - Not Active, not showing."); return; } - //menuBarItem!.PopoverMenu.Id = menuBarItem.Id; - // TODO: We should init the PopoverMenu in a smarter way if (menuBarItem?.PopoverMenu is { IsInitialized: false }) { @@ -258,31 +395,21 @@ public class MenuBarv2 : Menuv2, IDesignable } // If the active Application Popover is part of this MenuBar, hide it. - //HideActivePopover (); if (Application.Popover?.GetActivePopover () is PopoverMenu popoverMenu && popoverMenu?.Root?.SuperMenuItem?.SuperView == this) { + Logging.Debug ($"{Title} - Calling Application.Popover?.Hide ({popoverMenu.Title})"); Application.Popover?.Hide (popoverMenu); } if (menuBarItem is null) { + Logging.Debug ($"{Title} - menuBarItem is null."); + return; } - if (menuBarItem.PopoverMenu is { }) - { - menuBarItem.PopoverMenu.Accepted += (sender, args) => - { - if (HasFocus) - { - CanFocus = false; - } - }; - } - - _active = true; - CanFocus = true; + Active = true; menuBarItem.SetFocus (); if (menuBarItem.PopoverMenu?.Root is { }) @@ -290,22 +417,33 @@ public class MenuBarv2 : Menuv2, IDesignable menuBarItem.PopoverMenu.Root.SuperMenuItem = menuBarItem; } + Logging.Debug ($"{Title} - \"{menuBarItem.PopoverMenu?.Title}\".MakeVisible"); menuBarItem.PopoverMenu?.MakeVisible (new Point (menuBarItem.FrameToScreen ().X, menuBarItem.FrameToScreen ().Bottom)); + + menuBarItem.Accepting += OnMenuItemAccepted; + + return; + + void OnMenuItemAccepted (object? sender, EventArgs args) + { + Logging.Debug ($"{Title} - OnMenuItemAccepted"); + menuBarItem.PopoverMenu!.VisibleChanged -= OnMenuItemAccepted; + + if (Active && menuBarItem.PopoverMenu is { Visible: false }) + { + Active = false; + HasFocus = false; + } + } } - private MenuBarItemv2? GetActiveItem () - { - return SubViews.FirstOrDefault (sv => sv is MenuBarItemv2 { PopoverMenu: { Visible: true } }) as MenuBarItemv2; - } + private MenuBarItemv2? GetActiveItem () { return SubViews.OfType ().FirstOrDefault (sv => sv is { PopoverMenu: { Visible: true } }); } /// /// Hides the popover menu associated with the active menu bar item and updates the focus state. /// /// if the popover was hidden - public bool HideActiveItem () - { - return HideItem (GetActiveItem ()); - } + public bool HideActiveItem () { return HideItem (GetActiveItem ()); } /// /// Hides popover menu associated with the specified menu bar item and updates the focus state. @@ -314,67 +452,186 @@ public class MenuBarv2 : Menuv2, IDesignable /// if the popover was hidden public bool HideItem (MenuBarItemv2? activeItem) { + Logging.Debug ($"{Title} ({activeItem?.Title}) - Active: {Active}, CanFocus: {CanFocus}, HasFocus: {HasFocus}"); + if (activeItem is null || !activeItem.PopoverMenu!.Visible) { + Logging.Debug ($"{Title} No active item."); + return false; } - _active = false; - HasFocus = false; + + // IMPORTANT: Set Visible false before setting Active to false (Active changes Can/HasFocus) activeItem.PopoverMenu!.Visible = false; - CanFocus = false; + + Active = false; + HasFocus = false; return true; } - /// - public bool EnableForDesign (ref readonly TContext context) where TContext : notnull + /// + /// Gets all menu items with the specified Title, anywhere in the menu hierarchy. + /// + /// + /// + public IEnumerable GetMenuItemsWithTitle (string title) { + List menuItems = new (); + if (string.IsNullOrEmpty (title)) + { + return menuItems; + } + foreach (MenuBarItemv2 mbi in SubViews.OfType ()) + { + if (mbi.PopoverMenu is { }) + { + menuItems.AddRange (mbi.PopoverMenu.GetMenuItemsOfAllSubMenus ()); + } + } + return menuItems.Where (mi => mi.Title == title); + } + + /// + public bool EnableForDesign (ref TContext context) where TContext : notnull + { + // Note: This menu is used by unit tests. If you modify it, you'll likely have to update + // unit tests. + + Id = "DemonuBar"; + + var bordersCb = new CheckBox + { + Title = "_Borders", + CheckedState = CheckState.Checked + }; + + var autoSaveCb = new CheckBox + { + Title = "_Auto Save" + }; + + var enableOverwriteCb = new CheckBox + { + Title = "Enable _Overwrite", + }; + + var mutuallyExclusiveOptionsSelector = new OptionSelector + { + Options = ["G_ood", "_Bad", "U_gly"], + SelectedItem = 0 + }; + + var menuBgColorCp = new ColorPicker () + { + Width = 30 + }; + + menuBgColorCp.ColorChanged += (sender, args) => + { + ColorScheme = ColorScheme! with + { + Normal = new (ColorScheme.Normal.Foreground, args.CurrentValue) + }; + }; + Add ( - new MenuBarItemv2 ( - "_File", - [ - new MenuItemv2 (this, Command.New), - new MenuItemv2 (this, Command.Open), - new MenuItemv2 (this, Command.Save), - new MenuItemv2 (this, Command.SaveAs), - new Line (), - new MenuItemv2 - { - Title = "_Preferences", - SubMenu = new ( - [ - new MenuItemv2 - { - CommandView = new CheckBox () - { - Title = "O_ption", - }, - HelpText = "Toggle option" - }, - new MenuItemv2 - { - Title = "_Settings...", - HelpText = "More settings", - Action = () => MessageBox.Query ("Settings", "This is the Settings Dialog\n", ["_Ok", "_Cancel"]) - } - ] - ) - }, - new Line (), - new MenuItemv2 (this, Command.Quit) - ] - ) - ); + new MenuBarItemv2 ( + "_File", + [ + new MenuItemv2 (context as View, Command.New), + new MenuItemv2 (context as View, Command.Open), + new MenuItemv2 (context as View, Command.Save), + new MenuItemv2 (context as View, Command.SaveAs), + new Line (), + new MenuItemv2 + { + Title = "_File Options", + SubMenu = new ( + [ + new () + { + Id = "AutoSave", + Text = "(no Command)", + Key = Key.F10, + CommandView = autoSaveCb + }, + new () + { + Text = "Overwrite", + Id = "Overwrite", + Key = Key.W.WithCtrl, + CommandView = enableOverwriteCb, + Command = Command.EnableOverwrite, + TargetView = context as View + }, + new () + { + Title = "_File Settings...", + HelpText = "More file settings", + Action = () => MessageBox.Query ( + "File Settings", + "This is the File Settings Dialog\n", + "_Ok", + "_Cancel") + } + ] + ) + }, + new Line (), + new MenuItemv2 + { + Title = "_Preferences", + SubMenu = new ( + [ + new MenuItemv2 () + { + CommandView = bordersCb, + HelpText = "Toggle Menu Borders", + Action = ToggleMenuBorders + }, + new MenuItemv2 () + { + HelpText = "3 Mutually Exclusive Options", + CommandView = mutuallyExclusiveOptionsSelector, + Key = Key.F7 + }, + new Line (), + new MenuItemv2 () + { + HelpText = "MenuBar BG Color", + CommandView = menuBgColorCp, + Key = Key.F8, + } + ] + ) + }, + new Line (), + new MenuItemv2 () + { + TargetView = context as View, + Key = Application.QuitKey, + Command = Command.Quit + } + ] + ) + ); Add ( new MenuBarItemv2 ( "_Edit", [ - new MenuItemv2 (this, Command.Cut), - new MenuItemv2 (this, Command.Copy), - new MenuItemv2 (this, Command.Paste), + new MenuItemv2 (context as View, Command.Cut), + new MenuItemv2 (context as View, Command.Copy), + new MenuItemv2 (context as View, Command.Paste), new Line (), - new MenuItemv2 (this, Command.SelectAll) + new MenuItemv2 (context as View, Command.SelectAll), + new Line (), + new MenuItemv2 () + { + Title = "_Details", + SubMenu = new (ConfigureDetailsSubMenu ()) + }, ] ) ); @@ -396,6 +653,91 @@ public class MenuBarv2 : Menuv2, IDesignable ] ) ); + return true; + + void ToggleMenuBorders () + { + foreach (MenuBarItemv2 mbi in SubViews.OfType ()) + { + if (mbi is not { PopoverMenu: { } }) + { + continue; + } + + foreach (Menuv2? subMenu in mbi.PopoverMenu.GetAllSubMenus ()) + { + if (bordersCb.CheckedState == CheckState.Checked) + { + subMenu.Border!.Thickness = new (1); + } + else + { + subMenu.Border!.Thickness = new (0); + } + } + } + } + + MenuItemv2 [] ConfigureDetailsSubMenu () + { + var detail = new MenuItemv2 + { + Title = "_Detail 1", + Text = "Some detail #1" + }; + + var nestedSubMenu = new MenuItemv2 + { + Title = "_Moar Details", + SubMenu = new (ConfigureMoreDetailsSubMenu ()), + }; + + var editMode = new MenuItemv2 + { + Text = "App Binding to Command.Edit", + Id = "EditMode", + Command = Command.Edit, + CommandView = new CheckBox + { + Title = "E_dit Mode", + } + }; + + return [detail, nestedSubMenu, null!, editMode]; + + View [] ConfigureMoreDetailsSubMenu () + { + var deeperDetail = new MenuItemv2 + { + Title = "_Deeper Detail", + Text = "Deeper Detail", + Action = () => { MessageBox.Query ("Deeper Detail", "Lots of details", "_Ok"); } + }; + + var belowLineDetail = new MenuItemv2 + { + Title = "_Even more detail", + Text = "Below the line" + }; + + // This ensures the checkbox state toggles when the hotkey of Title is pressed. + //shortcut4.Accepting += (sender, args) => args.Cancel = true; + + return [deeperDetail, new Line (), belowLineDetail]; + } + } + + } + + /// + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + if (disposing) + { + SuperViewChanged += OnSuperViewChanged; + Applied -= OnConfigurationManagerApplied; + } } } diff --git a/Terminal.Gui/Views/Menu/MenuItemv2.cs b/Terminal.Gui/Views/Menu/MenuItemv2.cs index e57f96201..ca7cbf578 100644 --- a/Terminal.Gui/Views/Menu/MenuItemv2.cs +++ b/Terminal.Gui/Views/Menu/MenuItemv2.cs @@ -55,7 +55,7 @@ public class MenuItemv2 : Shortcut { } /// - public MenuItemv2 (string commandText, Key key, Action ? action = null) + public MenuItemv2 (string commandText, Key key, Action? action = null) : base (key ?? Key.Empty, commandText, action, null) { } @@ -104,35 +104,75 @@ public class MenuItemv2 : Shortcut internal override bool? DispatchCommand (ICommandContext? commandContext) { - Logging.Trace($"{commandContext?.Source?.Title}"); + Logging.Debug ($"{Title} - {commandContext?.Source?.Title} Command: {commandContext?.Command}"); bool? ret = null; - if (commandContext is { Command: not Command.HotKey }) + bool quit = false; + + if (commandContext is CommandContext keyCommandContext) + { + if (keyCommandContext.Binding.Key is { } && keyCommandContext.Binding.Key == Application.QuitKey && SuperView is { Visible: true }) + { + // This supports a MenuItem with Key = Application.QuitKey/Command = Command.Quit + Logging.Debug ($"{Title} - Ignoring Key = Application.QuitKey/Command = Command.Quit"); + quit = true; + //ret = true; + } + } + + // Translate the incoming command to Command + if (Command != Command.NotBound && commandContext is { }) + { + commandContext.Command = Command; + } + + if (!quit) { if (TargetView is { }) { - commandContext.Command = Command; + Logging.Debug ($"{Title} - InvokeCommand on TargetView ({TargetView.Title})..."); ret = TargetView.InvokeCommand (Command, commandContext); } else { // Is this an Application-bound command? + Logging.Debug ($"{Title} - Application.InvokeCommandsBoundToKey ({Key})..."); ret = Application.InvokeCommandsBoundToKey (Key); } } if (ret is not true) { - Logging.Trace($"Calling base.DispatchCommand"); + Logging.Debug ($"{Title} - calling base.DispatchCommand..."); + // Base will Raise Selected, then Accepting, then invoke the Action, if any ret = base.DispatchCommand (commandContext); } - Logging.Trace($"Calling RaiseAccepted"); - RaiseAccepted (commandContext); + if (ret is true) + { + Logging.Debug ($"{Title} - Calling RaiseAccepted"); + RaiseAccepted (commandContext); + } return ret; } + ///// + //protected override bool OnAccepting (CommandEventArgs e) + //{ + // Logging.Debug ($"{Title} - calling base.OnAccepting: {e.Context?.Command}"); + // bool? ret = base.OnAccepting (e); + + // if (ret is true || e.Cancel) + // { + // return true; + // } + + // //RaiseAccepted (e.Context); + + // return ret is true; + //} + private Menuv2? _subMenu; /// @@ -147,6 +187,7 @@ public class MenuItemv2 : Shortcut if (_subMenu is { }) { + SubMenu!.Visible = false; // TODO: This is a temporary hack - add a flag or something instead KeyView.Text = $"{Glyphs.RightArrow}"; _subMenu.SuperMenuItem = this; @@ -173,15 +214,13 @@ public class MenuItemv2 : Shortcut /// /// /// - protected bool? RaiseAccepted (ICommandContext? ctx) + protected void RaiseAccepted (ICommandContext? ctx) { - Logging.Trace ($"RaiseAccepted: {ctx}"); + //Logging.Trace ($"RaiseAccepted: {ctx}"); CommandEventArgs args = new () { Context = ctx }; OnAccepted (args); Accepted?.Invoke (this, args); - - return true; } /// diff --git a/Terminal.Gui/Views/Menu/Menuv2.cs b/Terminal.Gui/Views/Menu/Menuv2.cs index a8a0b9574..9bebb7603 100644 --- a/Terminal.Gui/Views/Menu/Menuv2.cs +++ b/Terminal.Gui/Views/Menu/Menuv2.cs @@ -15,14 +15,38 @@ public class Menuv2 : Bar /// public Menuv2 (IEnumerable? shortcuts) : base (shortcuts) { + // Do this to support debugging traces where Title gets set + base.HotKeySpecifier = (Rune)'\xffff'; + Orientation = Orientation.Vertical; Width = Dim.Auto (); Height = Dim.Auto (DimAutoStyle.Content, 1); + base.ColorScheme = Colors.ColorSchemes ["Menu"]; - Border!.Thickness = new Thickness (1, 1, 1, 1); - Border.LineStyle = LineStyle.Single; + if (Border is { }) + { + Border.Settings &= ~BorderSettings.Title; + } + + BorderStyle = DefaultBorderStyle; + + Applied += OnConfigurationManagerApplied; } + private void OnConfigurationManagerApplied (object? sender, ConfigurationManagerEventArgs e) + { + if (SuperView is { }) + { + BorderStyle = DefaultBorderStyle; + } + } + + /// + /// Gets or sets the default Border Style for Menus. The default is . + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope))] + public static LineStyle DefaultBorderStyle { get; set; } = LineStyle.Rounded; + /// /// Gets or sets the menu item that opened this menu as a sub-menu. /// @@ -37,16 +61,6 @@ public class Menuv2 : Bar } } - /// - public override void EndInit () - { - base.EndInit (); - - if (Border is { }) - { - } - } - /// protected override void OnSubViewAdded (View view) { @@ -58,7 +72,13 @@ public class Menuv2 : Bar { menuItem.CanFocus = true; - AddCommand (menuItem.Command, RaiseAccepted); + AddCommand (menuItem.Command, (ctx) => + { + RaiseAccepted (ctx); + + return true; + + }); menuItem.Accepted += MenuItemOnAccepted; @@ -66,7 +86,7 @@ public class Menuv2 : Bar void MenuItemOnAccepted (object? sender, CommandEventArgs e) { - Logging.Trace ($"MenuItemOnAccepted: {e.Context?.Source?.Title}"); + Logging.Debug ($"MenuItemOnAccepted: Calling RaiseAccepted {e.Context?.Source?.Title}"); RaiseAccepted (e.Context); } } @@ -79,15 +99,38 @@ public class Menuv2 : Bar } } + /// protected override bool OnAccepting (CommandEventArgs args) { - Logging.Trace ($"{args.Context}"); + // When the user accepts a menuItem, Menu.RaiseAccepting is called, and we intercept that here. - if (SuperMenuItem is { }) + Logging.Debug ($"{Title} - {args.Context?.Source?.Title} Command: {args.Context?.Command}"); + + // TODO: Consider having PopoverMenu subscribe to Accepting instead of us overriding OnAccepting here + // TODO: Doing so would be better encapsulation and might allow us to remove the SuperMenuItem property. + if (SuperView is { }) { - Logging.Trace ($"Invoking Accept on SuperMenuItem: {SuperMenuItem.Title}..."); - return SuperMenuItem?.SuperView?.InvokeCommand (Command.Accept, args.Context) is true; + Logging.Debug ($"{Title} - SuperView is null"); + //return false; + } + + Logging.Debug ($"{Title} - {args.Context}"); + + if (args.Context is CommandContext { Binding.Key: { } } keyCommandContext && keyCommandContext.Binding.Key == Application.QuitKey) + { + // Special case QuitKey if we are Visible - This supports a MenuItem with Key = Application.QuitKey/Command = Command.Quit + // And causes just the menu to quit. + Logging.Debug ($"{Title} - Returning true - Application.QuitKey/Command = Command.Quit"); + return true; + } + + // Because we may not have a SuperView (if we are in a PopoverMenu), we need to propagate + // Command.Accept to the SuperMenuItem if it exists. + if (SuperView is null && SuperMenuItem is { }) + { + Logging.Debug ($"{Title} - Invoking Accept on SuperMenuItem: {SuperMenuItem?.Title}..."); + return SuperMenuItem?.InvokeCommand (Command.Accept, args.Context) is true; } return false; } @@ -100,15 +143,13 @@ public class Menuv2 : Bar /// /// /// - protected bool? RaiseAccepted (ICommandContext? ctx) + protected void RaiseAccepted (ICommandContext? ctx) { //Logging.Trace ($"RaiseAccepted: {ctx}"); CommandEventArgs args = new () { Context = ctx }; OnAccepted (args); Accepted?.Invoke (this, args); - - return true; } /// @@ -158,7 +199,7 @@ public class Menuv2 : Bar internal void RaiseSelectedMenuItemChanged (MenuItemv2? selected) { - //Logging.Trace ($"RaiseSelectedMenuItemChanged: {selected?.Title}"); + Logging.Debug ($"{Title} ({selected?.Title})"); OnSelectedMenuItemChanged (selected); SelectedMenuItemChanged?.Invoke (this, selected); @@ -177,4 +218,14 @@ public class Menuv2 : Bar /// public event EventHandler? SelectedMenuItemChanged; + /// + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + + if (disposing) + { + Applied -= OnConfigurationManagerApplied; + } + } } \ No newline at end of file diff --git a/Terminal.Gui/Views/Menu/PopoverMenu.cs b/Terminal.Gui/Views/Menu/PopoverMenu.cs index a725750f7..ff891b7bb 100644 --- a/Terminal.Gui/Views/Menu/PopoverMenu.cs +++ b/Terminal.Gui/Views/Menu/PopoverMenu.cs @@ -4,7 +4,7 @@ namespace Terminal.Gui; /// /// Provides a cascading menu that pops over all other content. Can be used as a context menu or a drop-down /// all other content. Can be used as a context menu or a drop-down -/// menu as part of as part of . +/// menu as part of as part of . /// /// /// @@ -19,17 +19,39 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable /// public PopoverMenu () : this ((Menuv2?)null) { } - /// - public PopoverMenu (IEnumerable? menuItems) : this (new Menuv2 (menuItems)) { } + /// + /// Initializes a new instance of the class. If any of the elements of + /// is , + /// a see will be created instead. + /// + public PopoverMenu (IEnumerable? menuItems) : this ( + new Menuv2 (menuItems?.Select (item => item ?? new Line ())) + { + Title = "Popover Root" + }) + { } /// - public PopoverMenu (IEnumerable? menuItems) : this (new Menuv2 (menuItems)) { } + public PopoverMenu (IEnumerable? menuItems) : this ( + new Menuv2 (menuItems) + { + Title = "Popover Root" + }) + { } /// /// Initializes a new instance of the class with the specified root . /// public PopoverMenu (Menuv2? root) { + // Do this to support debugging traces where Title gets set + base.HotKeySpecifier = (Rune)'\xffff'; + + if (Border is { }) + { + Border.Settings &= ~BorderSettings.Title; + } + Key = DefaultKey; base.Visible = false; @@ -42,35 +64,39 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable AddCommand (Command.Left, MoveLeft); KeyBindings.Add (Key.CursorLeft, Command.Left); - // TODO: Remove; for debugging for now - AddCommand ( - Command.NotBound, - ctx => - { - Logging.Trace ($"popoverMenu NotBound: {ctx}"); - - return false; - }); - - KeyBindings.Add (Key, Command.Quit); - KeyBindings.ReplaceCommands (Application.QuitKey, Command.Quit); - - AddCommand ( - Command.Quit, - ctx => - { - if (!Visible) - { - return false; - } - - Visible = false; - - return false; - }); + AddCommand (Command.Quit, Quit); return; + bool? Quit (ICommandContext? ctx) + { + Logging.Debug ($"{Title} Command.Quit - {ctx?.Source?.Title}"); + + if (!Visible) + { + // If we're not visible, the command is not for us + return false; + } + + // This ensures the quit command gets propagated to the owner of the popover. + // This is important for MenuBarItems to ensure the MenuBar loses focus when + // the user presses QuitKey to cause the menu to close. + // Note, we override OnAccepting, which will set Visible to false + Logging.Debug ($"{Title} Command.Quit - Calling RaiseAccepting {ctx?.Source?.Title}"); + bool? ret = RaiseAccepting (ctx); + + if (Visible && ret is not true) + { + Visible = false; + + return true; + } + + // If we are Visible, returning true will stop the QuitKey from propagating + // If we are not Visible, returning false will allow the QuitKey to propagate + return Visible; + } + bool? MoveLeft (ICommandContext? ctx) { if (Focused == Root) @@ -97,7 +123,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable return true; } - return false; //AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop); + return false; } } @@ -138,6 +164,13 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable /// If , the current mouse position will be used. public void MakeVisible (Point? idealScreenPosition = null) { + if (Visible) + { + Logging.Debug ($"{Title} - Already Visible"); + + return; + } + UpdateKeyBindings (); SetPosition (idealScreenPosition); Application.Popover?.Show (this); @@ -177,6 +210,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable /// protected override void OnVisibleChanged () { + Logging.Debug ($"{Title} - Visible: {Visible}"); base.OnVisibleChanged (); if (Visible) @@ -205,20 +239,10 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable return; } - if (_root is { }) - { - _root.Accepting -= MenuOnAccepting; - } - HideAndRemoveSubMenu (_root); _root = value; - if (_root is { }) - { - _root.Accepting += MenuOnAccepting; - } - // TODO: This needs to be done whenever any MenuItem in the menu tree changes to support dynamic menus // TODO: And it needs to clear the old bindings first UpdateKeyBindings (); @@ -228,6 +252,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable foreach (Menuv2 menu in allMenus) { + menu.Visible = false; menu.Accepting += MenuOnAccepting; menu.Accepted += MenuAccepted; menu.SelectedMenuItemChanged += MenuOnSelectedMenuItemChanged; @@ -266,7 +291,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable menuItem.Key = key; - //Logging.Trace ($"HotKey: {menuItem.Key}->{menuItem.Command}"); + Logging.Debug ($"{Title} - HotKey: {menuItem.Key}->{menuItem.Command}"); } } @@ -278,8 +303,10 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable foreach (MenuItemv2 menuItem in all) { - if (menuItem.Key == key) + if (key != Application.QuitKey && menuItem.Key == key) { + Logging.Debug ($"{Title} - key: {key}"); + return menuItem.NewKeyDownEvent (key); } } @@ -291,7 +318,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable /// Gets all the submenus in the PopoverMenu. /// /// - internal IEnumerable GetAllSubMenus () + public IEnumerable GetAllSubMenus () { List result = []; @@ -310,7 +337,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable foreach (View subView in currentMenu.SubViews) { - if (subView is MenuItemv2 menuItem && menuItem.SubMenu != null) + if (subView is MenuItemv2 { SubMenu: { } } menuItem) { stack.Push (menuItem.SubMenu); } @@ -350,6 +377,8 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable { var menu = menuItem?.SuperView as Menuv2; + Logging.Debug ($"{Title} - menuItem: {menuItem?.Title}, menu: {menu?.Title}"); + menu?.Layout (); // If there's a visible peer, remove / hide it @@ -399,15 +428,13 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable private void AddAndShowSubMenu (Menuv2? menu) { - if (menu is { SuperView: null }) + if (menu is { SuperView: null, Visible: false }) { + Logging.Debug ($"{Title} ({menu?.Title}) - menu.Visible: {menu?.Visible}"); + // TODO: Find the menu item below the mouse, if any, and select it - // TODO: Enable No Border menu style - menu.Border!.LineStyle = LineStyle.Single; - menu.Border.Thickness = new (1); - - if (!menu.IsInitialized) + if (!menu!.IsInitialized) { menu.BeginInit (); menu.EndInit (); @@ -428,6 +455,8 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable { if (menu is { Visible: true }) { + Logging.Debug ($"{Title} ({menu?.Title}) - menu.Visible: {menu?.Visible}"); + // If there's a visible submenu, remove / hide it if (menu.SubViews.FirstOrDefault (v => v is MenuItemv2 { SubMenu.Visible: true }) is MenuItemv2 visiblePeer) { @@ -449,30 +478,75 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable private void MenuOnAccepting (object? sender, CommandEventArgs e) { var senderView = sender as View; - Logging.Trace ($"Sender: {senderView?.GetType ().Name}, {e.Context?.Source?.Title}"); + Logging.Debug ($"{Title} ({e.Context?.Source?.Title}) Command: {e.Context?.Command} - Sender: {senderView?.GetType ().Name}"); if (e.Context?.Command != Command.HotKey) { + Logging.Debug ($"{Title} - Setting Visible = false"); Visible = false; } - // This supports the case when a hotkey of a menuitem with a submenu is pressed - //e.Cancel = true; + if (e.Context is CommandContext keyCommandContext) + { + if (keyCommandContext.Binding.Key is { } && keyCommandContext.Binding.Key == Application.QuitKey && SuperView is { Visible: true }) + { + Logging.Debug ($"{Title} - Setting e.Cancel = true - Application.QuitKey/Command = Command.Quit"); + e.Cancel = true; + } + } } private void MenuAccepted (object? sender, CommandEventArgs e) { - //Logging.Trace ($"{e.Context?.Source?.Title}"); + Logging.Debug ($"{Title} ({e.Context?.Source?.Title}) Command: {e.Context?.Command}"); if (e.Context?.Source is MenuItemv2 { SubMenu: null }) { HideAndRemoveSubMenu (_root); - RaiseAccepted (e.Context); } else if (e.Context?.Source is MenuItemv2 { SubMenu: { } } menuItemWithSubMenu) { ShowSubMenu (menuItemWithSubMenu); } + + RaiseAccepted (e.Context); + } + + /// + protected override bool OnAccepting (CommandEventArgs args) + { + Logging.Debug ($"{Title} ({args.Context?.Source?.Title}) Command: {args.Context?.Command}"); + + // If we're not visible, ignore any keys that are not hotkeys + CommandContext? keyCommandContext = args.Context as CommandContext? ?? default (CommandContext); + + if (!Visible && keyCommandContext is { Binding.Key: { } }) + { + if (GetMenuItemsOfAllSubMenus ().All (i => i.Key != keyCommandContext.Value.Binding.Key)) + { + Logging.Debug ($"{Title} ({args.Context?.Source?.Title}) Command: {args.Context?.Command} - ignore any keys that are not hotkeys"); + + return false; + } + } + + Logging.Debug ($"{Title} - calling base.OnAccepting: {args.Context?.Command}"); + bool? ret = base.OnAccepting (args); + + if (ret is true || args.Cancel) + { + return args.Cancel = true; + } + + // Only raise Accepted if the command came from one of our MenuItems + //if (GetMenuItemsOfAllSubMenus ().Contains (args.Context?.Source)) + { + Logging.Debug ($"{Title} - Calling RaiseAccepted {args.Context?.Command}"); + RaiseAccepted (args.Context); + } + + // Always return false to enable accepting to continue propagating + return false; } /// @@ -481,15 +555,13 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable /// /// /// - protected bool? RaiseAccepted (ICommandContext? ctx) + protected void RaiseAccepted (ICommandContext? ctx) { - //Logging.Trace ($"RaiseAccepted: {ctx}"); + Logging.Debug ($"{Title} - RaiseAccepted: {ctx}"); CommandEventArgs args = new () { Context = ctx }; OnAccepted (args); Accepted?.Invoke (this, args); - - return true; } /// @@ -514,7 +586,7 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable private void MenuOnSelectedMenuItemChanged (object? sender, MenuItemv2? e) { - Logging.Trace ($"e: {e?.Title}"); + Logging.Debug ($"{Title} - e.Title: {e?.Title}"); ShowSubMenu (e); } @@ -551,18 +623,30 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable } /// - public bool EnableForDesign (ref readonly TContext context) where TContext : notnull + public bool EnableForDesign (ref TContext context) where TContext : notnull { + // Note: This menu is used by unit tests. If you modify it, you'll likely have to update + // unit tests. + Root = new ( [ - new MenuItemv2 (this, Command.Cut), - new MenuItemv2 (this, Command.Copy), - new MenuItemv2 (this, Command.Paste), + new MenuItemv2 (context as View, Command.Cut), + new MenuItemv2 (context as View, Command.Copy), + new MenuItemv2 (context as View, Command.Paste), new Line (), - new MenuItemv2 (this, Command.SelectAll) - ]); + new MenuItemv2 (context as View, Command.SelectAll), + new Line (), + new MenuItemv2 (context as View, Command.Quit) + ]) + { + Title = "Popover Demo Root" + }; - Visible = true; + // NOTE: This is a workaround for the fact that the PopoverMenu is not visible in the designer + // NOTE: without being activated via Application.Popover. But we want it to be visible. + // NOTE: If you use PopoverView.EnableForDesign for real Popover scenarios, change back to false + // NOTE: after calling EnableForDesign. + //Visible = true; return true; } diff --git a/Terminal.Gui/Views/Menuv1/Menu.cs b/Terminal.Gui/Views/Menuv1/Menu.cs index a4d241140..441eedf16 100644 --- a/Terminal.Gui/Views/Menuv1/Menu.cs +++ b/Terminal.Gui/Views/Menuv1/Menu.cs @@ -2,6 +2,8 @@ namespace Terminal.Gui; +#pragma warning disable CS0618 // Type or member is obsolete + /// /// An internal class used to represent a menu pop-up menu. Created and managed by . /// diff --git a/Terminal.Gui/Views/Menuv1/MenuBar.cs b/Terminal.Gui/Views/Menuv1/MenuBar.cs index bfde40c1b..80543388b 100644 --- a/Terminal.Gui/Views/Menuv1/MenuBar.cs +++ b/Terminal.Gui/Views/Menuv1/MenuBar.cs @@ -35,6 +35,7 @@ namespace Terminal.Gui; /// duplicates a shortcut (e.g. _File and Alt-F), the hot key wins. /// /// +[Obsolete ("Use MenuBarv2 instead.", false)] public class MenuBar : View, IDesignable { // Spaces before the Title @@ -1680,7 +1681,7 @@ public class MenuBar : View, IDesignable /// - public bool EnableForDesign (ref readonly TContext context) where TContext : notnull + public bool EnableForDesign (ref TContext context) where TContext : notnull { if (context is not Func actionFn) { diff --git a/Terminal.Gui/Views/Menuv1/MenuBarItem.cs b/Terminal.Gui/Views/Menuv1/MenuBarItem.cs index e68b1f87b..0a284bed3 100644 --- a/Terminal.Gui/Views/Menuv1/MenuBarItem.cs +++ b/Terminal.Gui/Views/Menuv1/MenuBarItem.cs @@ -6,6 +6,7 @@ namespace Terminal.Gui; /// is a menu item on . MenuBarItems do not support /// . /// +[Obsolete ("Use MenuBarItemv2 instead.", false)] public class MenuBarItem : MenuItem { /// Initializes a new as a . diff --git a/Terminal.Gui/Views/Menuv1/MenuClosingEventArgs.cs b/Terminal.Gui/Views/Menuv1/MenuClosingEventArgs.cs index b578e755c..c6f005eee 100644 --- a/Terminal.Gui/Views/Menuv1/MenuClosingEventArgs.cs +++ b/Terminal.Gui/Views/Menuv1/MenuClosingEventArgs.cs @@ -1,5 +1,7 @@ namespace Terminal.Gui; +#pragma warning disable CS0618 // Type or member is obsolete + /// An which allows passing a cancelable menu closing event. public class MenuClosingEventArgs : EventArgs { diff --git a/Terminal.Gui/Views/Menuv1/MenuItem.cs b/Terminal.Gui/Views/Menuv1/MenuItem.cs index d5dd714bc..7f5742f45 100644 --- a/Terminal.Gui/Views/Menuv1/MenuItem.cs +++ b/Terminal.Gui/Views/Menuv1/MenuItem.cs @@ -6,6 +6,8 @@ namespace Terminal.Gui; /// A has title, an associated help text, and an action to execute on activation. MenuItems /// can also have a checked indicator (see ). /// +[Obsolete ("Use MenuItemv2 instead.", false)] + public class MenuItem { internal MenuBar _menuBar; diff --git a/Terminal.Gui/Views/Menuv1/MenuOpenedEventArgs.cs b/Terminal.Gui/Views/Menuv1/MenuOpenedEventArgs.cs index 7bdc9df43..4e9879847 100644 --- a/Terminal.Gui/Views/Menuv1/MenuOpenedEventArgs.cs +++ b/Terminal.Gui/Views/Menuv1/MenuOpenedEventArgs.cs @@ -1,4 +1,5 @@ namespace Terminal.Gui; +#pragma warning disable CS0618 // Type or member is obsolete /// Defines arguments for the event public class MenuOpenedEventArgs : EventArgs diff --git a/Terminal.Gui/Views/Menuv1/MenuOpeningEventArgs.cs b/Terminal.Gui/Views/Menuv1/MenuOpeningEventArgs.cs index d7f23d36f..8956e0190 100644 --- a/Terminal.Gui/Views/Menuv1/MenuOpeningEventArgs.cs +++ b/Terminal.Gui/Views/Menuv1/MenuOpeningEventArgs.cs @@ -1,5 +1,7 @@ namespace Terminal.Gui; +#pragma warning disable CS0618 // Type or member is obsolete + /// /// An which allows passing a cancelable menu opening event or replacing with a new /// . diff --git a/Terminal.Gui/Views/OptionSelector.cs b/Terminal.Gui/Views/OptionSelector.cs new file mode 100644 index 000000000..02e1067d9 --- /dev/null +++ b/Terminal.Gui/Views/OptionSelector.cs @@ -0,0 +1,318 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Provides a user interface for displaying and selecting a single item from a list of options. +/// Each option is represented by a checkbox, but only one can be selected at a time. +/// +public class OptionSelector : View, IOrientation, IDesignable +{ + /// + /// Initializes a new instance of the class. + /// + public OptionSelector () + { + CanFocus = true; + + Width = Dim.Auto (DimAutoStyle.Content); + Height = Dim.Auto (DimAutoStyle.Content); + + _orientationHelper = new (this); + _orientationHelper.Orientation = Orientation.Vertical; + + // Accept (Enter key or DoubleClick) - Raise Accept event - DO NOT advance state + AddCommand (Command.Accept, HandleAcceptCommand); + + CreateCheckBoxes (); + } + + private bool? HandleAcceptCommand (ICommandContext? ctx) { return RaiseAccepting (ctx); } + + private int? _selectedItem; + + /// + /// Gets or sets the index of the selected item. + /// + public int? SelectedItem + { + get => _selectedItem; + set + { + if (_selectedItem == value) + { + return; + } + + int? previousSelectedItem = _selectedItem; + _selectedItem = value; + + UpdateChecked (); + + RaiseSelectedItemChanged (previousSelectedItem); + } + } + + private void RaiseSelectedItemChanged (int? previousSelectedItem) + { + OnSelectedItemChanged (SelectedItem, previousSelectedItem); + if (SelectedItem.HasValue) + { + SelectedItemChanged?.Invoke (this, new (SelectedItem, previousSelectedItem)); + } + } + + /// + /// Called when has changed. + /// + protected virtual void OnSelectedItemChanged (int? selectedItem, int? previousSelectedItem) { } + + /// + /// Raised when has changed. + /// + public event EventHandler? SelectedItemChanged; + + private IReadOnlyList? _options; + + /// + /// Gets or sets the list of options. + /// + public IReadOnlyList? Options + { + get => _options; + set + { + _options = value; + CreateCheckBoxes (); + } + } + + private bool _assignHotKeysToCheckBoxes; + + /// + /// If the CheckBoxes will each be automatically assigned a hotkey. + /// will be used to ensure unique keys are assigned. Set + /// before setting with any hotkeys that may conflict with other Views. + /// + public bool AssignHotKeysToCheckBoxes + { + get => _assignHotKeysToCheckBoxes; + set + { + if (_assignHotKeysToCheckBoxes == value) + { + return; + } + _assignHotKeysToCheckBoxes = value; + CreateCheckBoxes (); + UpdateChecked (); + } + } + + /// + /// Gets the list of hotkeys already used by the CheckBoxes or that should not be used if + /// + /// is enabled. + /// + public List UsedHotKeys { get; } = new (); + + private void CreateCheckBoxes () + { + if (Options is null) + { + return; + } + + foreach (CheckBox cb in RemoveAll ()) + { + cb.Dispose (); + } + + for (var index = 0; index < Options.Count; index++) + { + Add (CreateCheckBox (Options [index], index)); + } + + SetLayout (); + } + + /// + /// + /// + /// + /// + /// + protected virtual CheckBox CreateCheckBox (string name, int index) + { + string nameWithHotKey = name; + if (AssignHotKeysToCheckBoxes) + { + // Find the first char in label that is [a-z], [A-Z], or [0-9] + for (var i = 0; i < name.Length; i++) + { + char c = char.ToLowerInvariant (name [i]); + if (UsedHotKeys.Contains (new (c)) || !char.IsAsciiLetterOrDigit (c)) + { + continue; + } + + if (char.IsAsciiLetterOrDigit (c)) + { + char? hotChar = c; + nameWithHotKey = name.Insert (i, HotKeySpecifier.ToString ()); + UsedHotKeys.Add (new (hotChar)); + + break; + } + } + } + + var checkbox = new CheckBox + { + CanFocus = true, + Title = nameWithHotKey, + Id = name, + Data = index, + HighlightStyle = HighlightStyle.Hover, + RadioStyle = true + }; + + checkbox.GettingNormalColor += (_, e) => + { + if (SuperView is { HasFocus: true }) + { + e.Cancel = true; + + if (!HasFocus) + { + e.NewValue = GetFocusColor (); + } + else + { + // If _colorScheme was set, it's because of Hover + if (checkbox._colorScheme is { }) + { + e.NewValue = checkbox._colorScheme.Normal; + } + else + { + e.NewValue = GetNormalColor (); + } + } + } + }; + + checkbox.GettingHotNormalColor += (_, e) => + { + if (SuperView is { HasFocus: true }) + { + e.Cancel = true; + if (!HasFocus) + { + e.NewValue = GetHotFocusColor (); + } + else + { + // If _colorScheme was set, it's because of Hover + if (checkbox._colorScheme is { }) + { + e.NewValue = checkbox._colorScheme.Normal; + } + else + { + e.NewValue = GetNormalColor (); + } + } + } + }; + checkbox.Selecting += (sender, args) => + { + if (RaiseSelecting (args.Context) is true) + { + args.Cancel = true; + + return; + } + ; + + if (RaiseAccepting (args.Context) is true) + { + args.Cancel = true; + } + }; + + checkbox.CheckedStateChanged += (sender, args) => + { + if (checkbox.CheckedState == CheckState.Checked) + { + SelectedItem = index; + } + }; + + return checkbox; + } + + private void SetLayout () + { + foreach (View sv in SubViews) + { + if (Orientation == Orientation.Vertical) + { + sv.X = 0; + sv.Y = Pos.Align (Alignment.Start); + } + else + { + sv.X = Pos.Align (Alignment.Start); + sv.Y = 0; + sv.Margin!.Thickness = new (0, 0, 1, 0); + } + } + } + + private void UpdateChecked () + { + foreach (CheckBox cb in SubViews.OfType ()) + { + var index = (int)(cb.Data ?? throw new InvalidOperationException ("CheckBox.Data must be set")); + + cb.CheckedState = index == SelectedItem ? CheckState.Checked : CheckState.UnChecked; + } + } + + #region IOrientation + + /// + /// Gets or sets the for this . The default is + /// . + /// + public Orientation Orientation + { + get => _orientationHelper.Orientation; + set => _orientationHelper.Orientation = value; + } + + private readonly OrientationHelper _orientationHelper; + +#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. + /// + public void OnOrientationChanged (Orientation newOrientation) { SetLayout (); } + + #endregion IOrientation + + /// + public bool EnableForDesign () + { + AssignHotKeysToCheckBoxes = true; + Options = new [] { "Option 1", "Option 2", "Option 3" }; + + return true; + } +} diff --git a/Terminal.Gui/Views/RadioGroup.cs b/Terminal.Gui/Views/RadioGroup.cs index daefd8b44..9ff442ec0 100644 --- a/Terminal.Gui/Views/RadioGroup.cs +++ b/Terminal.Gui/Views/RadioGroup.cs @@ -41,8 +41,6 @@ public class RadioGroup : View, IDesignable, IOrientation MouseBindings.Add (MouseFlags.Button1DoubleClicked, Command.Accept); SubViewLayout += RadioGroup_LayoutStarted; - - HighlightStyle = HighlightStyle.PressedOutside | HighlightStyle.Pressed; } private bool? HandleHotKeyCommand (ICommandContext? ctx) @@ -281,23 +279,24 @@ public class RadioGroup : View, IDesignable, IOrientation // Pick a unique hotkey for each radio label for (var labelIndex = 0; labelIndex < value.Length; labelIndex++) { - string label = value [labelIndex]; - string? newLabel = label; + string name = value [labelIndex]; + string? nameWithHotKey = name; if (AssignHotKeysToRadioLabels) { // Find the first char in label that is [a-z], [A-Z], or [0-9] - for (var i = 0; i < label.Length; i++) + for (var i = 0; i < name.Length; i++) { - if (UsedHotKeys.Contains (new (label [i])) || !char.IsAsciiLetterOrDigit (label [i])) + char c = char.ToLowerInvariant (name [i]); + if (UsedHotKeys.Contains (new (c)) || !char.IsAsciiLetterOrDigit (c)) { continue; } - if (char.IsAsciiLetterOrDigit (label [i])) + if (char.IsAsciiLetterOrDigit (c)) { - char? hotChar = label [i]; - newLabel = label.Insert (i, HotKeySpecifier.ToString ()); + char? hotChar = c; + nameWithHotKey = name.Insert (i, HotKeySpecifier.ToString ()); UsedHotKeys.Add (new (hotChar)); break; @@ -305,9 +304,9 @@ public class RadioGroup : View, IDesignable, IOrientation } } - _radioLabels.Add (newLabel); + _radioLabels.Add (nameWithHotKey); - if (TextFormatter.FindHotKey (newLabel, HotKeySpecifier, out _, out Key hotKey)) + if (TextFormatter.FindHotKey (nameWithHotKey, HotKeySpecifier, out _, out Key hotKey)) { AddKeyBindingsForHotKey (Key.Empty, hotKey, labelIndex); } diff --git a/Terminal.Gui/Views/SelectedItemChangedArgs.cs b/Terminal.Gui/Views/SelectedItemChangedArgs.cs index a2f5eb47c..dca578b2d 100644 --- a/Terminal.Gui/Views/SelectedItemChangedArgs.cs +++ b/Terminal.Gui/Views/SelectedItemChangedArgs.cs @@ -1,4 +1,5 @@ -namespace Terminal.Gui; +#nullable enable +namespace Terminal.Gui; /// Event arguments for the SelectedItemChanged event. public class SelectedItemChangedArgs : EventArgs @@ -6,15 +7,15 @@ public class SelectedItemChangedArgs : EventArgs /// Initializes a new class. /// /// - public SelectedItemChangedArgs (int selectedItem, int previousSelectedItem) + public SelectedItemChangedArgs (int? selectedItem, int? previousSelectedItem) { PreviousSelectedItem = previousSelectedItem; SelectedItem = selectedItem; } - /// Gets the index of the item that was previously selected. -1 if there was no previous selection. - public int PreviousSelectedItem { get; } + /// Gets the index of the item that was previously selected. null if there was no previous selection. + public int? PreviousSelectedItem { get; } - /// Gets the index of the item that is now selected. -1 if there is no selection. - public int SelectedItem { get; } + /// Gets the index of the item that is now selected. null if there is no selection. + public int? SelectedItem { get; } } diff --git a/Terminal.Gui/Views/Shortcut.cs b/Terminal.Gui/Views/Shortcut.cs index 7f98a9400..ed1464ff7 100644 --- a/Terminal.Gui/Views/Shortcut.cs +++ b/Terminal.Gui/Views/Shortcut.cs @@ -60,8 +60,6 @@ public class Shortcut : View, IOrientation, IDesignable /// The help text to display. public Shortcut (Key key, string? commandText, Action? action, string? helpText = null) { - Id = $"shortcut:{commandText}"; - HighlightStyle = HighlightStyle.None; CanFocus = true; @@ -90,11 +88,11 @@ public class Shortcut : View, IOrientation, IDesignable Title = commandText ?? string.Empty; HelpView.Id = "_helpView"; - HelpView.CanFocus = false; + //HelpView.CanFocus = false; HelpView.Text = helpText ?? string.Empty; KeyView.Id = "_keyView"; - KeyView.CanFocus = false; + //KeyView.CanFocus = false; key ??= Key.Empty; Key = key; @@ -119,18 +117,6 @@ public class Shortcut : View, IOrientation, IDesignable // Once Frame.Width gets below this value, LayoutStarted makes HelpView an KeyView smaller. private int? _minimumNaturalWidth; - /// - protected override bool OnHighlight (CancelEventArgs args) - { - if (args.NewValue.HasFlag (HighlightStyle.Hover)) - { - SetFocus (); - return true; - } - - return false; - } - /// /// Gets or sets the for this . /// @@ -176,9 +162,6 @@ public class Shortcut : View, IOrientation, IDesignable Add (KeyView); SetKeyViewDefaultLayout (); } - - // BUGBUG: Causes ever other layout to lose focus colors - //SetColors (); } // Force Width to DimAuto to calculate natural width and then set it back @@ -260,44 +243,54 @@ public class Shortcut : View, IOrientation, IDesignable } /// - /// Called when a Command has been invoked on this Shortcut. + /// Dispatches the Command in the (Raises Selected, then Accepting, then invoke the Action, if any). + /// Called when Command.Select, Accept, or HotKey has been invoked on this Shortcut. /// /// - /// + /// + /// if no event was raised; input processing should continue. + /// if the event was raised and was not handled (or cancelled); input processing should continue. + /// if the event was raised and handled (or cancelled); input processing should stop. + /// internal virtual bool? DispatchCommand (ICommandContext? commandContext) { - Logging.Trace($"{commandContext?.Source?.Title}"); CommandContext? keyCommandContext = commandContext as CommandContext? ?? default (CommandContext); + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) Command: {commandContext?.Command}"); + if (keyCommandContext?.Binding.Data != this) { + // TODO: Optimize this to only do this if CommandView is custom (non View) // Invoke Select on the CommandView to cause it to change state if it wants to // If this causes CommandView to raise Accept, we eat it keyCommandContext = keyCommandContext!.Value with { Binding = keyCommandContext.Value.Binding with { Data = this } }; - Logging.Trace ($"Invoking Select on CommandView."); + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) - Invoking Select on CommandView ({CommandView.GetType ().Name})."); CommandView.InvokeCommand (Command.Select, keyCommandContext); } - // BUGBUG: Why does this use keyCommandContext and not commandContext? - Logging.Trace ($"RaiseSelecting ..."); - if (RaiseSelecting (keyCommandContext) is true) + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) - RaiseSelecting ..."); + + if (RaiseSelecting (commandContext) is true) { return true; } - // The default HotKey handler sets Focus - Logging.Trace ($"SetFocus..."); - SetFocus (); + if (CanFocus && SuperView is { CanFocus: true }) + { + // The default HotKey handler sets Focus + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) - SetFocus..."); + SetFocus (); + } var cancel = false; - if (commandContext is { }) + if (commandContext is { Source: null }) { commandContext.Source = this; } - Logging.Trace ($"RaiseAccepting..."); + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) - Calling RaiseAccepting..."); cancel = RaiseAccepting (commandContext) is true; if (cancel) @@ -305,21 +298,15 @@ public class Shortcut : View, IOrientation, IDesignable return true; } - if (commandContext?.Command != Command.Accept) - { - // return false; - } - if (Action is { }) { - Logging.Trace ($"Invoke Action..."); + Logging.Debug ($"{Title} ({commandContext?.Source?.Title}) - Invoke Action..."); Action.Invoke (); // Assume if there's a subscriber to Action, it's handled. cancel = true; } - return cancel; } @@ -437,7 +424,7 @@ public class Shortcut : View, IOrientation, IDesignable // The default behavior is for CommandView to not get focus. I // If you want it to get focus, you need to set it. - _commandView.CanFocus = false; + // _commandView.CanFocus = false; _commandView.HotKeyChanged += (s, e) => { @@ -492,9 +479,31 @@ public class Shortcut : View, IOrientation, IDesignable CommandView.VerticalTextAlignment = Alignment.Center; CommandView.TextAlignment = Alignment.Start; CommandView.TextFormatter.WordWrap = false; - CommandView.HighlightStyle = HighlightStyle.None; + //CommandView.HighlightStyle = HighlightStyle.None; + CommandView.GettingNormalColor += CommandViewOnGettingNormalColor; + CommandView.GettingHotNormalColor += CommandViewOnGettingHotNormalColor; + } + private void CommandViewOnGettingNormalColor (object? sender, CancelEventArgs e) + { + if (HasFocus) + { + e.Cancel = true; + e.NewValue = GetFocusColor (); + } + } + + private void CommandViewOnGettingHotNormalColor (object? sender, CancelEventArgs e) + { + if (HasFocus && e is { }) + { + e.Cancel = true; + e.NewValue = GetHotFocusColor (); + } + } + + private void Shortcut_TitleChanged (object? sender, EventArgs e) { // If the Title changes, update the CommandView text. @@ -533,6 +542,9 @@ public class Shortcut : View, IOrientation, IDesignable HelpView.TextAlignment = Alignment.Start; HelpView.TextFormatter.WordWrap = false; HelpView.HighlightStyle = HighlightStyle.None; + + HelpView.GettingNormalColor += CommandViewOnGettingNormalColor; + HelpView.GettingHotNormalColor += CommandViewOnGettingHotNormalColor; } /// @@ -619,10 +631,10 @@ public class Shortcut : View, IOrientation, IDesignable } /// - /// Gets the subview that displays the key. Internal for unit testing. + /// Gets the subview that displays the key. Is drawn with Normal and HotNormal colors reversed. /// - public View KeyView { get; } = new (); + public ShortcutKeyView KeyView { get; } = new (); private int _minimumKeyTextSize; @@ -698,17 +710,6 @@ public class Shortcut : View, IOrientation, IDesignable #region Focus - /// - public override ColorScheme? ColorScheme - { - get => base.ColorScheme; - set - { - base.ColorScheme = _nonFocusColorScheme = value; - SetColors (); - } - } - private bool _forceFocusColors; /// @@ -720,78 +721,31 @@ public class Shortcut : View, IOrientation, IDesignable set { _forceFocusColors = value; - SetColors (value); - //SetNeedsDraw(); + SetNeedsDraw (); } } - private ColorScheme? _nonFocusColorScheme; - - /// - /// - internal void SetColors (bool highlight = false) + /// + public override Attribute GetNormalColor () { - if (HasFocus || highlight || ForceFocusColors) + if (HasFocus) { - if (_nonFocusColorScheme is null) - { - _nonFocusColorScheme = base.ColorScheme; - } - - base.ColorScheme ??= new (Attribute.Default); - - // When we have focus, we invert the colors - base.ColorScheme = new (base.ColorScheme) - { - Normal = GetFocusColor (), - HotNormal = GetHotFocusColor (), - HotFocus = GetHotNormalColor (), - Focus = GetNormalColor (), - }; - } - else - { - if (_nonFocusColorScheme is { }) - { - base.ColorScheme = _nonFocusColorScheme; - //_nonFocusColorScheme = null; - } - else - { - base.ColorScheme = SuperView?.ColorScheme ?? base.ColorScheme; - } + return base.GetFocusColor (); } - // Set KeyView's colors to show "hot" - if (IsInitialized && base.ColorScheme is { }) - { - var cs = new ColorScheme (base.ColorScheme) - { - Normal = GetHotNormalColor (), - HotNormal = GetNormalColor () - }; - KeyView.ColorScheme = cs; - } - - if (CommandView.Margin is { }) - { - CommandView.Margin.ColorScheme = base.ColorScheme; - } - if (HelpView.Margin is { }) - { - HelpView.Margin.ColorScheme = base.ColorScheme; - } - - if (KeyView.Margin is { }) - { - KeyView.Margin.ColorScheme = base.ColorScheme; - } + return base.GetNormalColor (); } - /// - protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? view) + /// + public override Attribute GetHotNormalColor () { - SetColors (); + if (HasFocus) + + { + return base.GetHotFocusColor (); + } + + return base.GetHotNormalColor (); } #endregion Focus @@ -832,3 +786,21 @@ public class Shortcut : View, IOrientation, IDesignable base.Dispose (disposing); } } + +/// +/// A helper class used by to display the key. Reverses the Normal and HotNormal colors. +/// +public class ShortcutKeyView : View +{ + /// + public override Attribute GetNormalColor () + { + if (SuperView is { HasFocus: true }) + + { + return base.GetHotFocusColor (); + } + + return base.GetHotNormalColor (); + } +} diff --git a/Terminal.Gui/Views/StatusBar.cs b/Terminal.Gui/Views/StatusBar.cs index 57743989f..38238f744 100644 --- a/Terminal.Gui/Views/StatusBar.cs +++ b/Terminal.Gui/Views/StatusBar.cs @@ -1,5 +1,4 @@ -using System; -using System.Reflection; +#nullable enable namespace Terminal.Gui; @@ -23,16 +22,45 @@ public class StatusBar : Bar, IDesignable Y = Pos.AnchorEnd (); Width = Dim.Fill (); Height = Dim.Auto (DimAutoStyle.Content, 1); - BorderStyle = LineStyle.Dashed; - ColorScheme = Colors.ColorSchemes ["Menu"]; - SubViewLayout += StatusBar_LayoutStarted; + if (Border is { }) + { + Border.LineStyle = DefaultSeparatorLineStyle; + } + + base.ColorScheme = Colors.ColorSchemes ["Menu"]; + + Applied += OnConfigurationManagerApplied; + SuperViewChanged += OnSuperViewChanged; } - // StatusBar arranges the items horizontally. - // The first item has no left border, the last item has no right border. - // The Shortcuts are configured with the command, help, and key views aligned in reverse order (EndToStart). - private void StatusBar_LayoutStarted (object sender, LayoutEventArgs e) + private void OnSuperViewChanged (object? sender, SuperViewChangedEventArgs e) + { + if (SuperView is null) + { + // BUGBUG: This is a hack for avoiding a race condition in ConfigurationManager.Apply + // BUGBUG: For some reason in some unit tests, when Top is disposed, MenuBar.Dispose does not get called. + // BUGBUG: Yet, the MenuBar does get Removed from Top (and it's SuperView set to null). + // BUGBUG: Related: https://github.com/gui-cs/Terminal.Gui/issues/4021 + Applied -= OnConfigurationManagerApplied; + } + } + private void OnConfigurationManagerApplied (object? sender, ConfigurationManagerEventArgs e) + { + if (Border is { }) + { + Border.LineStyle = DefaultSeparatorLineStyle; + } + } + + /// + /// Gets or sets the default Line Style for the separators between the shortcuts of the StatusBar. + /// + [SerializableConfigurationProperty (Scope = typeof (ThemeScope))] + public static LineStyle DefaultSeparatorLineStyle { get; set; } = LineStyle.Dashed; + + /// + protected override void OnSubViewLayout (LayoutEventArgs args) { for (int index = 0; index < SubViews.Count; index++) { @@ -40,13 +68,9 @@ public class StatusBar : Bar, IDesignable barItem.BorderStyle = BorderStyle; - if (index == SubViews.Count - 1) + if (barItem.Border is { }) { - barItem.Border.Thickness = new Thickness (0, 0, 0, 0); - } - else - { - barItem.Border.Thickness = new Thickness (0, 0, 1, 0); + barItem.Border.Thickness = index == SubViews.Count - 1 ? new Thickness (0, 0, 0, 0) : new Thickness (0, 0, 1, 0); } if (barItem is Shortcut shortcut) @@ -54,6 +78,7 @@ public class StatusBar : Bar, IDesignable shortcut.Orientation = Orientation.Horizontal; } } + base.OnSubViewLayout (args); } /// @@ -108,7 +133,7 @@ public class StatusBar : Bar, IDesignable Text = "I'll Hide", // Visible = false }; - button1.Accepting += Button_Clicked; + button1.Accepting += OnButtonClicked; Add (button1); shortcut.Accepting += (s, e) => @@ -135,7 +160,15 @@ public class StatusBar : Bar, IDesignable return true; - void Button_Clicked (object sender, EventArgs e) { MessageBox.Query ("Hi", $"You clicked {sender}"); } + void OnButtonClicked (object? sender, EventArgs? e) { MessageBox.Query ("Hi", $"You clicked {sender}"); } } + /// + protected override void Dispose (bool disposing) + { + base.Dispose (disposing); + + SuperViewChanged -= OnSuperViewChanged; + Applied -= OnConfigurationManagerApplied; + } } diff --git a/Terminal.Gui/Views/Wizard/WizardStep.cs b/Terminal.Gui/Views/Wizard/WizardStep.cs index cf1b781da..617a47c19 100644 --- a/Terminal.Gui/Views/Wizard/WizardStep.cs +++ b/Terminal.Gui/Views/Wizard/WizardStep.cs @@ -163,10 +163,12 @@ public class WizardStep : View /// Removes all s from the . /// - public override void RemoveAll () + public override IReadOnlyCollection RemoveAll () { - _contentView.RemoveAll (); + IReadOnlyCollection removed = _contentView.RemoveAll (); ShowHide (); + + return removed; } /// Does the work to show and hide the contentView and helpView as appropriate diff --git a/TerminalGuiFluentTesting/FakeInput.cs b/TerminalGuiFluentTesting/FakeInput.cs index e90844382..fa57c164d 100644 --- a/TerminalGuiFluentTesting/FakeInput.cs +++ b/TerminalGuiFluentTesting/FakeInput.cs @@ -23,12 +23,12 @@ internal class FakeInput : IConsoleInput /// public void Initialize (ConcurrentQueue inputBuffer) { InputBuffer = inputBuffer; } - public ConcurrentQueue InputBuffer { get; set; } + public ConcurrentQueue? InputBuffer { get; set; } /// public void Run (CancellationToken token) { // Blocks until either the token or the hardStopToken is cancelled. - WaitHandle.WaitAny (new [] { token.WaitHandle, _hardStopToken.WaitHandle, _timeoutCts.Token.WaitHandle }); + WaitHandle.WaitAny ([token.WaitHandle, _hardStopToken.WaitHandle, _timeoutCts.Token.WaitHandle]); } } diff --git a/TerminalGuiFluentTesting/GuiTestContext.cs b/TerminalGuiFluentTesting/GuiTestContext.cs index 9b9fe7af4..5efc590e7 100644 --- a/TerminalGuiFluentTesting/GuiTestContext.cs +++ b/TerminalGuiFluentTesting/GuiTestContext.cs @@ -3,13 +3,12 @@ using System.Text; using Microsoft.Extensions.Logging; using Terminal.Gui; using Terminal.Gui.ConsoleDrivers; -using static Unix.Terminal.Curses; namespace TerminalGuiFluentTesting; /// -/// Fluent API context for testing a Terminal.Gui application. Create -/// an instance using static class. +/// Fluent API context for testing a Terminal.Gui application. Create +/// an instance using static class. /// public class GuiTestContext : IDisposable { @@ -23,7 +22,7 @@ public class GuiTestContext : IDisposable private View? _lastView; private readonly StringBuilder _logsSb; private readonly V2TestDriver _driver; - private bool _finished=false; + private bool _finished; internal GuiTestContext (Func topLevelBuilder, int width, int height, V2TestDriver driver) { @@ -68,6 +67,7 @@ public class GuiTestContext : IDisposable t.Closed += (s, e) => { _finished = true; }; Application.Run (t); // This will block, but it's on a background thread now + t.Dispose (); Application.Shutdown (); } catch (OperationCanceledException) @@ -97,12 +97,12 @@ public class GuiTestContext : IDisposable private string GetDriverName () { return _driver switch - { - V2TestDriver.V2Win => "v2win", - V2TestDriver.V2Net => "v2net", - _ => - throw new ArgumentOutOfRangeException () - }; + { + V2TestDriver.V2Win => "v2win", + V2TestDriver.V2Net => "v2net", + _ => + throw new ArgumentOutOfRangeException () + }; } /// @@ -115,7 +115,7 @@ public class GuiTestContext : IDisposable return this; } - Application.Invoke (() => {Application.RequestStop ();}); + Application.Invoke (() => { Application.RequestStop (); }); // Wait for the application to stop, but give it a 1-second timeout if (!_runTask.Wait (TimeSpan.FromMilliseconds (1000))) @@ -148,7 +148,7 @@ public class GuiTestContext : IDisposable } /// - /// Cleanup to avoid state bleed between tests + /// Cleanup to avoid state bleed between tests /// public void Dispose () { @@ -184,7 +184,7 @@ public class GuiTestContext : IDisposable } /// - /// Simulates changing the console size e.g. by resizing window in your operating system + /// Simulates changing the console size e.g. by resizing window in your operating system /// /// new Width for the console. /// new Height for the console. @@ -203,11 +203,11 @@ public class GuiTestContext : IDisposable writer.WriteLine (text); - return WaitIteration (); + return this; //WaitIteration(); } /// - /// Writes all Terminal.Gui engine logs collected so far to the + /// Writes all Terminal.Gui engine logs collected so far to the /// /// /// @@ -215,12 +215,12 @@ public class GuiTestContext : IDisposable { writer.WriteLine (_logsSb.ToString ()); - return WaitIteration (); + return this; //WaitIteration(); } /// - /// Waits until the end of the current iteration of the main loop. Optionally - /// running a given action on the UI thread at that time. + /// Waits until the end of the current iteration of the main loop. Optionally + /// running a given action on the UI thread at that time. /// /// /// @@ -255,8 +255,8 @@ public class GuiTestContext : IDisposable } /// - /// Performs the supplied immediately. - /// Enables running commands without breaking the Fluent API calls. + /// Performs the supplied immediately. + /// Enables running commands without breaking the Fluent API calls. /// /// /// @@ -266,22 +266,20 @@ public class GuiTestContext : IDisposable { doAction (); } - catch(Exception) + catch (Exception) { HardStop (); throw; - } return this; } - /// - /// Simulates a right click at the given screen coordinates on the current driver. - /// This is a raw input event that goes through entire processing pipeline as though - /// user had pressed the mouse button physically. + /// Simulates a right click at the given screen coordinates on the current driver. + /// This is a raw input event that goes through entire processing pipeline as though + /// user had pressed the mouse button physically. /// /// 0 indexed screen coordinates /// 0 indexed screen coordinates @@ -289,29 +287,25 @@ public class GuiTestContext : IDisposable public GuiTestContext RightClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button3Pressed, screenX, screenY); } /// - /// Simulates a left click at the given screen coordinates on the current driver. - /// This is a raw input event that goes through entire processing pipeline as though - /// user had pressed the mouse button physically. + /// Simulates a left click at the given screen coordinates on the current driver. + /// This is a raw input event that goes through entire processing pipeline as though + /// user had pressed the mouse button physically. /// /// 0 indexed screen coordinates /// 0 indexed screen coordinates /// - public GuiTestContext LeftClick (int screenX, int screenY) - { - return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); - } + public GuiTestContext LeftClick (int screenX, int screenY) { return Click (WindowsConsole.ButtonState.Button1Pressed, screenX, screenY); } - public GuiTestContext LeftClick (Func evaluator) where T : View - { - return Click (WindowsConsole.ButtonState.Button1Pressed,evaluator); - } + public GuiTestContext LeftClick (Func evaluator) where T : View { return Click (WindowsConsole.ButtonState.Button1Pressed, evaluator); } - private GuiTestContext Click (WindowsConsole.ButtonState btn, Func evaluator) where T:View + private GuiTestContext Click (WindowsConsole.ButtonState btn, Func evaluator) where T : View { - var v = Find (evaluator); - var screen = v.ViewportToScreen (new Point (0, 0)); + T v = Find (evaluator); + Point screen = v.ViewportToScreen (new Point (0, 0)); + return Click (btn, screen.X, screen.Y); } + private GuiTestContext Click (WindowsConsole.ButtonState btn, int screenX, int screenY) { switch (_driver) @@ -339,29 +333,32 @@ public class GuiTestContext : IDisposable MousePosition = new ((short)screenX, (short)screenY) } }); + break; case V2TestDriver.V2Net: int netButton = btn switch - { - WindowsConsole.ButtonState.Button1Pressed => 0, - WindowsConsole.ButtonState.Button2Pressed => 1, - WindowsConsole.ButtonState.Button3Pressed => 2, - WindowsConsole.ButtonState.RightmostButtonPressed => 2, - _ => throw new ArgumentOutOfRangeException (nameof (btn)) - }; - foreach (var k in NetSequences.Click (netButton, screenX, screenY)) + { + WindowsConsole.ButtonState.Button1Pressed => 0, + WindowsConsole.ButtonState.Button2Pressed => 1, + WindowsConsole.ButtonState.Button3Pressed => 2, + WindowsConsole.ButtonState.RightmostButtonPressed => 2, + _ => throw new ArgumentOutOfRangeException (nameof (btn)) + }; + + foreach (ConsoleKeyInfo k in NetSequences.Click (netButton, screenX, screenY)) { SendNetKey (k); } + break; default: throw new ArgumentOutOfRangeException (); } - WaitIteration (); + return WaitIteration (); - return this; + ; } public GuiTestContext Down () @@ -370,24 +367,26 @@ public class GuiTestContext : IDisposable { case V2TestDriver.V2Win: SendWindowsKey (ConsoleKeyMapping.VK.DOWN); - WaitIteration (); + break; case V2TestDriver.V2Net: - foreach (var k in NetSequences.Down) + foreach (ConsoleKeyInfo k in NetSequences.Down) { SendNetKey (k); } + break; default: throw new ArgumentOutOfRangeException (); } + return WaitIteration (); - return this; + ; } /// - /// Simulates the Right cursor key + /// Simulates the Right cursor key /// /// /// @@ -397,24 +396,26 @@ public class GuiTestContext : IDisposable { case V2TestDriver.V2Win: SendWindowsKey (ConsoleKeyMapping.VK.RIGHT); - WaitIteration (); + break; case V2TestDriver.V2Net: - foreach (var k in NetSequences.Right) + foreach (ConsoleKeyInfo k in NetSequences.Right) { SendNetKey (k); } + WaitIteration (); + break; default: throw new ArgumentOutOfRangeException (); } - return this; + return WaitIteration (); } /// - /// Simulates the Left cursor key + /// Simulates the Left cursor key /// /// /// @@ -424,23 +425,24 @@ public class GuiTestContext : IDisposable { case V2TestDriver.V2Win: SendWindowsKey (ConsoleKeyMapping.VK.LEFT); - WaitIteration (); + break; case V2TestDriver.V2Net: - foreach (var k in NetSequences.Left) + foreach (ConsoleKeyInfo k in NetSequences.Left) { SendNetKey (k); } + break; default: throw new ArgumentOutOfRangeException (); } - return this; + return WaitIteration (); } /// - /// Simulates the up cursor key + /// Simulates the up cursor key /// /// /// @@ -450,23 +452,24 @@ public class GuiTestContext : IDisposable { case V2TestDriver.V2Win: SendWindowsKey (ConsoleKeyMapping.VK.UP); - WaitIteration (); + break; case V2TestDriver.V2Net: - foreach (var k in NetSequences.Up) + foreach (ConsoleKeyInfo k in NetSequences.Up) { SendNetKey (k); } + break; default: throw new ArgumentOutOfRangeException (); } - return this; + return WaitIteration (); } /// - /// Simulates pressing the Return/Enter (newline) key. + /// Simulates pressing the Return/Enter (newline) key. /// /// /// @@ -484,20 +487,21 @@ public class GuiTestContext : IDisposable wVirtualKeyCode = ConsoleKeyMapping.VK.RETURN, wVirtualScanCode = 28 }); + break; case V2TestDriver.V2Net: SendNetKey (new ('\r', ConsoleKey.Enter, false, false, false)); + break; default: throw new ArgumentOutOfRangeException (); } - return this; + return WaitIteration (); } - /// - /// Simulates pressing the Esc (Escape) key. + /// Simulates pressing the Esc (Escape) key. /// /// /// @@ -515,12 +519,14 @@ public class GuiTestContext : IDisposable wVirtualKeyCode = ConsoleKeyMapping.VK.ESCAPE, wVirtualScanCode = 1 }); + break; case V2TestDriver.V2Net: // Note that this accurately describes how Esc comes in. Typically, ConsoleKey is None // even though you would think it would be Escape - it isn't SendNetKey (new ('\u001b', ConsoleKey.None, false, false, false)); + break; default: throw new ArgumentOutOfRangeException (); @@ -529,10 +535,8 @@ public class GuiTestContext : IDisposable return this; } - - /// - /// Simulates pressing the Tab key. + /// Simulates pressing the Tab key. /// /// /// @@ -550,12 +554,14 @@ public class GuiTestContext : IDisposable wVirtualKeyCode = 0, wVirtualScanCode = 0 }); + break; case V2TestDriver.V2Net: // Note that this accurately describes how Tab comes in. Typically, ConsoleKey is None // even though you would think it would be Tab - it isn't SendNetKey (new ('\t', ConsoleKey.None, false, false, false)); + break; default: throw new ArgumentOutOfRangeException (); @@ -565,8 +571,8 @@ public class GuiTestContext : IDisposable } /// - /// Registers a right click handler on the added view (or root view) that - /// will open the supplied . + /// Registers a right click handler on the added view (or root view) that + /// will open the supplied . /// /// /// @@ -587,7 +593,7 @@ public class GuiTestContext : IDisposable } /// - /// The last view added (e.g. with ) or the root/current top. + /// The last view added (e.g. with ) or the root/current top. /// public View LastView => _lastView ?? Application.Top ?? throw new ("Could not determine which view to add to"); @@ -620,11 +626,7 @@ public class GuiTestContext : IDisposable WaitIteration (); } - - private void SendNetKey (ConsoleKeyInfo consoleKeyInfo) - { - _netInput.InputBuffer.Enqueue (consoleKeyInfo); - } + private void SendNetKey (ConsoleKeyInfo consoleKeyInfo) { _netInput.InputBuffer.Enqueue (consoleKeyInfo); } /// /// Sends a special key e.g. cursor key that does not map to a specific character @@ -666,10 +668,23 @@ public class GuiTestContext : IDisposable } /// - /// Sets the input focus to the given . - /// Throws if focus did not change due to system - /// constraints e.g. - /// is + /// Sends a key to the application. This goes directly to Application and does not go through + /// a driver. + /// + /// + /// + public GuiTestContext RaiseKeyDownEvent (Key key) + { + Application.RaiseKeyDownEvent (key); + + return this; //WaitIteration(); + } + + /// + /// Sets the input focus to the given . + /// Throws if focus did not change due to system + /// constraints e.g. + /// is /// /// /// @@ -687,27 +702,28 @@ public class GuiTestContext : IDisposable } /// - /// Tabs through the UI until a View matching the - /// is found (of Type T) or all views are looped through (back to the beginning) - /// in which case triggers hard stop and Exception + /// Tabs through the UI until a View matching the + /// is found (of Type T) or all views are looped through (back to the beginning) + /// in which case triggers hard stop and Exception /// /// /// - public GuiTestContext Focus (Func evaluator) where T:View + public GuiTestContext Focus (Func evaluator) where T : View { - var t = Application.Top; + Toplevel? t = Application.Top; HashSet seen = new (); if (t == null) { Fail ("Application.Top was null when trying to set focus"); + return this; } do { - var next = t.MostFocused; + View? next = t.MostFocused; // Is view found? if (next is T v && evaluator (v)) @@ -716,13 +732,14 @@ public class GuiTestContext : IDisposable } // No, try tab to the next (or first) - this.Tab (); + Tab (); WaitIteration (); next = t.MostFocused; if (next is null) { Fail ("Failed to tab to a view which matched the Type and evaluator constraints of the test because MostFocused became or was always null"); + return this; } @@ -734,22 +751,20 @@ public class GuiTestContext : IDisposable return this; } - } while (true); } - - private T Find (Func evaluator) where T : View { - var t = Application.Top; + Toplevel? t = Application.Top; if (t == null) { Fail ("Application.Top was null when attempting to find view"); } - var f = FindRecursive(t!, evaluator); + + T? f = FindRecursive (t!, evaluator); if (f == null) { @@ -761,7 +776,7 @@ public class GuiTestContext : IDisposable private T? FindRecursive (View current, Func evaluator) where T : View { - foreach (var subview in current.SubViews) + foreach (View subview in current.SubViews) { if (subview is T match && evaluator (match)) { @@ -769,7 +784,8 @@ public class GuiTestContext : IDisposable } // Recursive call - var result = FindRecursive (subview, evaluator); + T? result = FindRecursive (subview, evaluator); + if (result != null) { return result; @@ -783,8 +799,7 @@ public class GuiTestContext : IDisposable { Stop (); - throw new Exception (reason); - + throw new (reason); } public GuiTestContext Send (Key key) @@ -798,6 +813,7 @@ public class GuiTestContext : IDisposable { Fail ("Expected Application.Driver to be IConsoleDriverFacade"); } + return this; } } diff --git a/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs b/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs index f79325319..d47ca128e 100644 --- a/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs +++ b/Tests/IntegrationTests/FluentTests/BasicFluentAssertionTests.cs @@ -8,13 +8,47 @@ public class BasicFluentAssertionTests { private readonly TextWriter _out; - public BasicFluentAssertionTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); } + public BasicFluentAssertionTests (ITestOutputHelper outputHelper) + { + _out = new TestOutputWriter (outputHelper); + } + + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void GuiTestContext_NewInstance_Runs (V2TestDriver d) + { + using GuiTestContext context = With.A (40, 10, d); + Assert.True (Application.Top!.Running); + + context.WriteOutLogs (_out); + context.Stop (); + } + + + [Theory] + [ClassData (typeof (V2TestDrivers))] + public void GuiTestContext_QuitKey_Stops (V2TestDriver d) + { + using GuiTestContext context = With.A (40, 10, d); + Assert.True (Application.Top!.Running); + + Toplevel top = Application.Top; + context.RaiseKeyDownEvent (Application.QuitKey); + Assert.False (top!.Running); + + Application.Top?.Dispose (); + Application.Shutdown(); + + context.WriteOutLogs (_out); + context.Stop (); + } [Theory] [ClassData (typeof (V2TestDrivers))] public void GuiTestContext_StartsAndStopsWithoutError (V2TestDriver d) { - using GuiTestContext context = With.A (40, 10,d); + using GuiTestContext context = With.A (40, 10, d); // No actual assertions are needed — if no exceptions are thrown, it's working context.Stop (); @@ -51,10 +85,10 @@ public class BasicFluentAssertionTests { var clicked = false; - MenuItemv2 [] menuItems = [new ("_New File", string.Empty, () => { clicked = true; })]; + MenuItemv2 [] menuItems = [new ("_New File", string.Empty, () => { clicked = true; })]; using GuiTestContext c = With.A (40, 10, d) - .WithContextMenu (new PopoverMenu(menuItems)) + .WithContextMenu (new PopoverMenu (menuItems)) .ScreenShot ("Before open menu", _out) // Click in main area inside border @@ -90,7 +124,7 @@ public class BasicFluentAssertionTests new ("Six", "", null) ]; - using GuiTestContext c = With.A (40, 10,d) + using GuiTestContext c = With.A (40, 10, d) .WithContextMenu (new PopoverMenu (menuItems)) .ScreenShot ("Before open menu", _out) @@ -100,7 +134,7 @@ public class BasicFluentAssertionTests .Down () .Down () .Down () - .Right() + .Right () .ScreenShot ("After open submenu", _out) .Down () .Enter () diff --git a/Tests/IntegrationTests/FluentTests/FileDialogFluentTests.cs b/Tests/IntegrationTests/FluentTests/FileDialogFluentTests.cs index 2eeb59484..b1a3ced79 100644 --- a/Tests/IntegrationTests/FluentTests/FileDialogFluentTests.cs +++ b/Tests/IntegrationTests/FluentTests/FileDialogFluentTests.cs @@ -11,7 +11,10 @@ public class FileDialogFluentTests { private readonly TextWriter _out; - public FileDialogFluentTests (ITestOutputHelper outputHelper) { _out = new TestOutputWriter (outputHelper); } + public FileDialogFluentTests (ITestOutputHelper outputHelper) + { + _out = new TestOutputWriter (outputHelper); + } private MockFileSystem CreateExampleFileSystem () { @@ -41,27 +44,25 @@ public class FileDialogFluentTests [ClassData (typeof (V2TestDrivers))] public void CancelFileDialog_UsingEscape (V2TestDriver d) { - var sd = new SaveDialog ( CreateExampleFileSystem ()); + var sd = new SaveDialog (CreateExampleFileSystem ()); using var c = With.A (sd, 100, 20, d) - .ScreenShot ("Save dialog",_out) - .Escape() + .ScreenShot ("Save dialog", _out) + .Escape () + .Then (() => Assert.True (sd.Canceled)) .Stop (); - - Assert.True (sd.Canceled); } [Theory] [ClassData (typeof (V2TestDrivers))] public void CancelFileDialog_UsingCancelButton_TabThenEnter (V2TestDriver d) { - var sd = new SaveDialog (CreateExampleFileSystem ()); + var sd = new SaveDialog (CreateExampleFileSystem ()) { Modal = false }; using var c = With.A (sd, 100, 20, d) .ScreenShot ("Save dialog", _out) - .Focus