diff --git a/Terminal.Gui/Application/Application.Initialization.cs b/Terminal.Gui/Application/Application.Initialization.cs index 56e774a14d..a74f1545d1 100644 --- a/Terminal.Gui/Application/Application.Initialization.cs +++ b/Terminal.Gui/Application/Application.Initialization.cs @@ -37,7 +37,15 @@ public static partial class Application // Initialization (Init/Shutdown) /// </param> [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public static void Init (IConsoleDriver? driver = null, string? driverName = null) { InternalInit (driver, driverName); } + public static void Init (IConsoleDriver? driver = null, string? driverName = null) + { + if (driverName?.StartsWith ("v2") ?? false) + { + ApplicationImpl.ChangeInstance (new ApplicationV2 ()); + } + + ApplicationImpl.Instance.Init (driver, driverName); + } internal static int MainThreadId { get; set; } = -1; @@ -94,19 +102,7 @@ internal static void InternalInit ( AddKeyBindings (); - // Start the process of configuration management. - // Note that we end up calling LoadConfigurationFromAllSources - // multiple times. We need to do this because some settings are only - // valid after a Driver is loaded. In this case we need just - // `Settings` so we can determine which driver to use. - // Don't reset, so we can inherit the theme from the previous run. - string previousTheme = Themes?.Theme ?? string.Empty; - Load (); - if (Themes is { } && !string.IsNullOrEmpty (previousTheme) && previousTheme != "Default") - { - ThemeManager.SelectedTheme = previousTheme; - } - Apply (); + InitializeConfigurationManagement (); // Ignore Configuration for ForceDriver if driverName is specified if (!string.IsNullOrEmpty (driverName)) @@ -166,12 +162,28 @@ internal static void InternalInit ( SynchronizationContext.SetSynchronizationContext (new MainLoopSyncContext ()); - SupportedCultures = GetSupportedCultures (); MainThreadId = Thread.CurrentThread.ManagedThreadId; bool init = Initialized = true; InitializedChanged?.Invoke (null, new (init)); } + internal static void InitializeConfigurationManagement () + { + // Start the process of configuration management. + // Note that we end up calling LoadConfigurationFromAllSources + // multiple times. We need to do this because some settings are only + // valid after a Driver is loaded. In this case we need just + // `Settings` so we can determine which driver to use. + // Don't reset, so we can inherit the theme from the previous run. + string previousTheme = Themes?.Theme ?? string.Empty; + Load (); + if (Themes is { } && !string.IsNullOrEmpty (previousTheme) && previousTheme != "Default") + { + ThemeManager.SelectedTheme = previousTheme; + } + Apply (); + } + internal static void SubscribeDriverEvents () { ArgumentNullException.ThrowIfNull (Driver); @@ -226,20 +238,7 @@ internal static void UnsubscribeDriverEvents () /// up (Disposed) /// and terminal settings are restored. /// </remarks> - public static void Shutdown () - { - // TODO: Throw an exception if Init hasn't been called. - - bool wasInitialized = Initialized; - ResetState (); - PrintJsonErrors (); - - if (wasInitialized) - { - bool init = Initialized; - InitializedChanged?.Invoke (null, new (in init)); - } - } + public static void Shutdown () => ApplicationImpl.Instance.Shutdown (); /// <summary> /// Gets whether the application has been initialized with <see cref="Init"/> and not yet shutdown with <see cref="Shutdown"/>. @@ -258,4 +257,12 @@ public static void Shutdown () /// Intended to support unit tests that need to know when the application has been initialized. /// </remarks> public static event EventHandler<EventArgs<bool>>? InitializedChanged; + + /// <summary> + /// Raises the <see cref="InitializedChanged"/> event. + /// </summary> + internal static void OnInitializedChanged (object sender, EventArgs<bool> e) + { + Application.InitializedChanged?.Invoke (sender,e); + } } diff --git a/Terminal.Gui/Application/Application.Keyboard.cs b/Terminal.Gui/Application/Application.Keyboard.cs index 625b5b15f2..873dc0af0f 100644 --- a/Terminal.Gui/Application/Application.Keyboard.cs +++ b/Terminal.Gui/Application/Application.Keyboard.cs @@ -177,7 +177,6 @@ internal static void AddKeyBindings () return true; } ); - AddCommand ( Command.Suspend, static () => @@ -187,7 +186,6 @@ internal static void AddKeyBindings () return true; } ); - AddCommand ( Command.NextTabStop, static () => Navigation?.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop)); diff --git a/Terminal.Gui/Application/Application.Run.cs b/Terminal.Gui/Application/Application.Run.cs index 9e4d43b705..fc6819aed1 100644 --- a/Terminal.Gui/Application/Application.Run.cs +++ b/Terminal.Gui/Application/Application.Run.cs @@ -305,7 +305,8 @@ internal static bool PositionCursor () /// <returns>The created <see cref="Toplevel"/> object. The caller is responsible for disposing this object.</returns> [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public static Toplevel Run (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) { return Run<Toplevel> (errorHandler, driver); } + public static Toplevel Run (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) => + ApplicationImpl.Instance.Run (errorHandler, driver); /// <summary> /// Runs the application by creating a <see cref="Toplevel"/>-derived object of type <c>T</c> and calling @@ -331,20 +332,7 @@ internal static bool PositionCursor () [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static T Run<T> (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) - where T : Toplevel, new() - { - if (!Initialized) - { - // Init() has NOT been called. - InternalInit (driver, null, true); - } - - var top = new T (); - - Run (top, errorHandler); - - return top; - } + where T : Toplevel, new() => ApplicationImpl.Instance.Run<T> (errorHandler, driver); /// <summary>Runs the Application using the provided <see cref="Toplevel"/> view.</summary> /// <remarks> @@ -385,73 +373,7 @@ public static T Run<T> (Func<Exception, bool>? errorHandler = null, IConsoleDriv /// rethrows when null). /// </param> public static void Run (Toplevel view, Func<Exception, bool>? errorHandler = null) - { - ArgumentNullException.ThrowIfNull (view); - - if (Initialized) - { - if (Driver is null) - { - // Disposing before throwing - view.Dispose (); - - // This code path should be impossible because Init(null, null) will select the platform default driver - throw new InvalidOperationException ( - "Init() completed without a driver being set (this should be impossible); Run<T>() cannot be called." - ); - } - } - else - { - // Init() has NOT been called. - throw new InvalidOperationException ( - "Init() has not been called. Only Run() or Run<T>() can be used without calling Init()." - ); - } - - var resume = true; - - while (resume) - { -#if !DEBUG - try - { -#endif - resume = false; - RunState runState = Begin (view); - - // If EndAfterFirstIteration is true then the user must dispose of the runToken - // by using NotifyStopRunState event. - RunLoop (runState); - - if (runState.Toplevel is null) - { -#if DEBUG_IDISPOSABLE - Debug.Assert (TopLevels.Count == 0); -#endif - runState.Dispose (); - - return; - } - - if (!EndAfterFirstIteration) - { - End (runState); - } -#if !DEBUG - } - catch (Exception error) - { - if (errorHandler is null) - { - throw; - } - - resume = errorHandler (error); - } -#endif - } - } + => ApplicationImpl.Instance.Run (view, errorHandler); /// <summary>Adds a timeout to the application.</summary> /// <remarks> @@ -459,36 +381,23 @@ public static void Run (Toplevel view, Func<Exception, bool>? errorHandler = nul /// reset, repeating the invocation. If it returns false, the timeout will stop and be removed. The returned value is a /// token that can be used to stop the timeout by calling <see cref="RemoveTimeout(object)"/>. /// </remarks> - public static object? AddTimeout (TimeSpan time, Func<bool> callback) - { - return MainLoop?.AddTimeout (time, callback) ?? null; - } + public static object? AddTimeout (TimeSpan time, Func<bool> callback) => ApplicationImpl.Instance.AddTimeout (time, callback); /// <summary>Removes a previously scheduled timeout</summary> /// <remarks>The token parameter is the value returned by <see cref="AddTimeout"/>.</remarks> /// Returns - /// <c>true</c> + /// <see langword="true"/> /// if the timeout is successfully removed; otherwise, - /// <c>false</c> + /// <see langword="false"/> /// . /// This method also returns - /// <c>false</c> + /// <see langword="false"/> /// if the timeout is not found. - public static bool RemoveTimeout (object token) { return MainLoop?.RemoveTimeout (token) ?? false; } + public static bool RemoveTimeout (object token) => ApplicationImpl.Instance.RemoveTimeout (token); /// <summary>Runs <paramref name="action"/> on the thread that is processing events</summary> /// <param name="action">the action to be invoked on the main processing thread.</param> - public static void Invoke (Action action) - { - MainLoop?.AddIdle ( - () => - { - action (); - - return false; - } - ); - } + public static void Invoke (Action action) => ApplicationImpl.Instance.Invoke (action); // TODO: Determine if this is really needed. The only code that calls WakeUp I can find // is ProgressBarStyles, and it's not clear it needs to. @@ -517,8 +426,7 @@ public static void LayoutAndDraw (bool forceDraw = false) View.SetClipToScreen (); View.Draw (TopLevels, neededLayout || forceDraw); - View.SetClipToScreen (); - + View.SetClipToScreen (); Driver?.Refresh (); } @@ -528,7 +436,7 @@ public static void LayoutAndDraw (bool forceDraw = false) /// <summary>The <see cref="MainLoop"/> driver for the application</summary> /// <value>The main loop.</value> - internal static MainLoop? MainLoop { get; private set; } + internal static MainLoop? MainLoop { get; set; } /// <summary> /// Set to true to cause <see cref="End"/> to be called after the first iteration. Set to false (the default) to @@ -612,31 +520,8 @@ public static bool RunIteration (ref RunState state, bool firstIteration = false /// property on the currently running <see cref="Toplevel"/> to false. /// </para> /// </remarks> - public static void RequestStop (Toplevel? top = null) - { - if (top is null) - { - top = Top; - } - - if (!top!.Running) - { - return; - } - - var ev = new ToplevelClosingEventArgs (top); - top.OnClosing (ev); - - if (ev.Cancel) - { - return; - } - - top.Running = false; - OnNotifyStopRunState (top); - } - - private static void OnNotifyStopRunState (Toplevel top) + public static void RequestStop (Toplevel? top = null) => ApplicationImpl.Instance.RequestStop (top); + internal static void OnNotifyStopRunState (Toplevel top) { if (EndAfterFirstIteration) { diff --git a/Terminal.Gui/Application/Application.cd b/Terminal.Gui/Application/Application.cd new file mode 100644 index 0000000000..9c22dd77ba --- /dev/null +++ b/Terminal.Gui/Application/Application.cd @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="utf-8"?> +<ClassDiagram MajorVersion="1" MinorVersion="1"> + <Class Name="Terminal.Gui.Application"> + <Position X="2.25" Y="1.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>hEI4FAgAqARIspQfBQo0gTGiACNL0AICESJKoggBSg8=</HashCode> + <FileName>Application\Application.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.ApplicationNavigation" Collapsed="true"> + <Position X="13.75" Y="1.75" Width="2" /> + <TypeIdentifier> + <HashCode>AABAAAAAAABCAAAAAAAAAAAAAAAAIgIAAAAAAAAAAAA=</HashCode> + <FileName>Application\ApplicationNavigation.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.IterationEventArgs" Collapsed="true"> + <Position X="16" Y="2" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>Application\IterationEventArgs.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.MainLoop" Collapsed="true" BaseTypeListCollapsed="true"> + <Position X="10.25" Y="2.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>CAAAIAAAASAAAQAQAAAAAIBADQAAEAAYIgIIwAAAAAI=</HashCode> + <FileName>Application\MainLoop.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" Collapsed="true" /> + </Class> + <Class Name="Terminal.Gui.MainLoopSyncContext" Collapsed="true"> + <Position X="12" Y="2.75" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAgAAAAAAAAAAAEAAAAAACAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>Application\MainLoopSyncContext.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.RunState" Collapsed="true" BaseTypeListCollapsed="true"> + <Position X="14.25" Y="3" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAACACAgAAAAAAAAAAAAAAAAACQAAAAAAAAAA=</HashCode> + <FileName>Application\RunState.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" Collapsed="true" /> + </Class> + <Class Name="Terminal.Gui.RunStateEventArgs" Collapsed="true"> + <Position X="16" Y="3" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAA=</HashCode> + <FileName>Application\RunStateEventArgs.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.Timeout" Collapsed="true"> + <Position X="10.25" Y="3.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAQAA=</HashCode> + <FileName>Application\Timeout.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.TimeoutEventArgs" Collapsed="true"> + <Position X="12" Y="3.75" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAACAIAAAAAAAAAAAA=</HashCode> + <FileName>Application\TimeoutEventArgs.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.ApplicationImpl" BaseTypeListCollapsed="true"> + <Position X="5.75" Y="1.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAQAACAACAAAI=</HashCode> + <FileName>Application\ApplicationImpl.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Interface Name="Terminal.Gui.IMainLoopDriver" Collapsed="true"> + <Position X="12" Y="5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAACAAAAAQAAAAABAAAAAAAEAAAAAAAAAAAAAA=</HashCode> + <FileName>Application\MainLoop.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IApplication"> + <Position X="4" Y="1.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAACAAAAAAI=</HashCode> + <FileName>Application\IApplication.cs</FileName> + </TypeIdentifier> + </Interface> + <Font Name="Segoe UI" Size="9" /> +</ClassDiagram> \ No newline at end of file diff --git a/Terminal.Gui/Application/Application.cs b/Terminal.Gui/Application/Application.cs index 79198349f1..a493f30569 100644 --- a/Terminal.Gui/Application/Application.cs +++ b/Terminal.Gui/Application/Application.cs @@ -24,7 +24,7 @@ namespace Terminal.Gui; public static partial class Application { /// <summary>Gets all cultures supported by the application without the invariant language.</summary> - public static List<CultureInfo>? SupportedCultures { get; private set; } + public static List<CultureInfo>? SupportedCultures { get; private set; } = GetSupportedCultures (); /// <summary> /// Gets a string representation of the Application as rendered by <see cref="Driver"/>. @@ -224,5 +224,10 @@ internal static void ResetState (bool ignoreDisposed = false) SynchronizationContext.SetSynchronizationContext (null); } - // Only return true if the Current has changed. + + /// <summary> + /// 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. + /// </summary> + public static void AddIdle (Func<bool> func) => ApplicationImpl.Instance.AddIdle (func); } diff --git a/Terminal.Gui/Application/ApplicationImpl.cs b/Terminal.Gui/Application/ApplicationImpl.cs new file mode 100644 index 0000000000..b6b8f91c42 --- /dev/null +++ b/Terminal.Gui/Application/ApplicationImpl.cs @@ -0,0 +1,296 @@ +#nullable enable +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Terminal.Gui; + +/// <summary> +/// Original Terminal.Gui implementation of core <see cref="Application"/> methods. +/// </summary> +public class ApplicationImpl : IApplication +{ + // Private static readonly Lazy instance of Application + private static Lazy<IApplication> _lazyInstance = new (() => new ApplicationImpl ()); + + /// <summary> + /// Gets the currently configured backend implementation of <see cref="Application"/> gateway methods. + /// Change to your own implementation by using <see cref="ChangeInstance"/> (before init). + /// </summary> + public static IApplication Instance => _lazyInstance.Value; + + /// <summary> + /// Change the singleton implementation, should not be called except before application + /// startup. This method lets you provide alternative implementations of core static gateway + /// methods of <see cref="Application"/>. + /// </summary> + /// <param name="newApplication"></param> + public static void ChangeInstance (IApplication newApplication) + { + _lazyInstance = new Lazy<IApplication> (newApplication); + } + + /// <inheritdoc/> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public virtual void Init (IConsoleDriver? driver = null, string? driverName = null) + { + Application.InternalInit (driver, driverName); + } + + /// <summary> + /// Runs the application by creating a <see cref="Toplevel"/> object and calling + /// <see cref="Run(Toplevel, Func{Exception, bool})"/>. + /// </summary> + /// <remarks> + /// <para>Calling <see cref="Init"/> first is not needed as this function will initialize the application.</para> + /// <para> + /// <see cref="Shutdown"/> must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// </para> + /// <para> + /// The caller is responsible for disposing the object returned by this method. + /// </para> + /// </remarks> + /// <returns>The created <see cref="Toplevel"/> object. The caller is responsible for disposing this object.</returns> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public Toplevel Run (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) { return Run<Toplevel> (errorHandler, driver); } + + /// <summary> + /// Runs the application by creating a <see cref="Toplevel"/>-derived object of type <c>T</c> and calling + /// <see cref="Run(Toplevel, Func{Exception, bool})"/>. + /// </summary> + /// <remarks> + /// <para>Calling <see cref="Init"/> first is not needed as this function will initialize the application.</para> + /// <para> + /// <see cref="Shutdown"/> must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// </para> + /// <para> + /// The caller is responsible for disposing the object returned by this method. + /// </para> + /// </remarks> + /// <param name="errorHandler"></param> + /// <param name="driver"> + /// The <see cref="IConsoleDriver"/> to use. If not specified the default driver for the platform will + /// be used ( <see cref="WindowsDriver"/>, <see cref="CursesDriver"/>, or <see cref="NetDriver"/>). Must be + /// <see langword="null"/> if <see cref="Init"/> has already been called. + /// </param> + /// <returns>The created T object. The caller is responsible for disposing this object.</returns> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public virtual T Run<T> (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) + where T : Toplevel, new() + { + if (!Application.Initialized) + { + // Init() has NOT been called. + Application.InternalInit (driver, null, true); + } + + var top = new T (); + + Run (top, errorHandler); + + return top; + } + + /// <summary>Runs the Application using the provided <see cref="Toplevel"/> view.</summary> + /// <remarks> + /// <para> + /// This method is used to start processing events for the main application, but it is also used to run other + /// modal <see cref="View"/>s such as <see cref="Dialog"/> boxes. + /// </para> + /// <para> + /// To make a <see cref="Run(Terminal.Gui.Toplevel,System.Func{System.Exception,bool})"/> stop execution, call + /// <see cref="Application.RequestStop"/>. + /// </para> + /// <para> + /// Calling <see cref="Run(Terminal.Gui.Toplevel,System.Func{System.Exception,bool})"/> is equivalent to calling + /// <see cref="Application.Begin(Toplevel)"/>, followed by <see cref="Application.RunLoop(RunState)"/>, and then calling + /// <see cref="Application.End(RunState)"/>. + /// </para> + /// <para> + /// Alternatively, to have a program control the main loop and process events manually, call + /// <see cref="Application.Begin(Toplevel)"/> to set things up manually and then repeatedly call + /// <see cref="Application.RunLoop(RunState)"/> with the wait parameter set to false. By doing this the + /// <see cref="Application.RunLoop(RunState)"/> method will only process any pending events, timers, idle handlers and then + /// return control immediately. + /// </para> + /// <para>When using <see cref="Run{T}"/> or + /// <see cref="Run(System.Func{System.Exception,bool},Terminal.Gui.IConsoleDriver)"/> + /// <see cref="Init"/> will be called automatically. + /// </para> + /// <para> + /// RELEASE builds only: When <paramref name="errorHandler"/> is <see langword="null"/> any exceptions will be + /// rethrown. Otherwise, if <paramref name="errorHandler"/> will be called. If <paramref name="errorHandler"/> + /// returns <see langword="true"/> the <see cref="Application.RunLoop(RunState)"/> will resume; otherwise this method will + /// exit. + /// </para> + /// </remarks> + /// <param name="view">The <see cref="Toplevel"/> to run as a modal.</param> + /// <param name="errorHandler"> + /// RELEASE builds only: Handler for any unhandled exceptions (resumes when returns true, + /// rethrows when null). + /// </param> + public virtual void Run (Toplevel view, Func<Exception, bool>? errorHandler = null) + { + ArgumentNullException.ThrowIfNull (view); + + if (Application.Initialized) + { + if (Application.Driver is null) + { + // Disposing before throwing + view.Dispose (); + + // This code path should be impossible because Init(null, null) will select the platform default driver + throw new InvalidOperationException ( + "Init() completed without a driver being set (this should be impossible); Run<T>() cannot be called." + ); + } + } + else + { + // Init() has NOT been called. + throw new InvalidOperationException ( + "Init() has not been called. Only Run() or Run<T>() can be used without calling Init()." + ); + } + + var resume = true; + + while (resume) + { +#if !DEBUG + try + { +#endif + 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 (runState.Toplevel is null) + { +#if DEBUG_IDISPOSABLE + Debug.Assert (Application.TopLevels.Count == 0); +#endif + runState.Dispose (); + + return; + } + + if (!Application.EndAfterFirstIteration) + { + Application.End (runState); + } +#if !DEBUG + } + catch (Exception error) + { + if (errorHandler is null) + { + throw; + } + + resume = errorHandler (error); + } +#endif + } + } + + /// <summary>Shutdown an application initialized with <see cref="Init"/>.</summary> + /// <remarks> + /// Shutdown must be called for every call to <see cref="Init"/> or + /// <see cref="Application.Run(Toplevel, Func{Exception, bool})"/> to ensure all resources are cleaned + /// up (Disposed) + /// and terminal settings are restored. + /// </remarks> + public virtual void Shutdown () + { + // TODO: Throw an exception if Init hasn't been called. + + bool wasInitialized = Application.Initialized; + Application.ResetState (); + PrintJsonErrors (); + + if (wasInitialized) + { + bool init = Application.Initialized; + + Application.OnInitializedChanged(this, new (in init)); + } + } + + /// <inheritdoc /> + public virtual void RequestStop (Toplevel? top) + { + top ??= Application.Top; + + if (!top!.Running) + { + return; + } + + var ev = new ToplevelClosingEventArgs (top); + top.OnClosing (ev); + + if (ev.Cancel) + { + return; + } + + top.Running = false; + Application.OnNotifyStopRunState (top); + } + + /// <inheritdoc /> + public virtual void Invoke (Action action) + { + Application.MainLoop?.AddIdle ( + () => + { + action (); + + return false; + } + ); + } + + /// <inheritdoc /> + public bool IsLegacy { get; protected set; } = true; + + /// <inheritdoc /> + public virtual void AddIdle (Func<bool> func) + { + if(Application.MainLoop is null) + { + throw new NotInitializedException ("Cannot add idle before main loop is initialized"); + } + + // Yes in this case we cannot go direct via TimedEvents because legacy main loop + // has established behaviour to do other stuff too e.g. 'wake up'. + Application.MainLoop.AddIdle (func); + + } + + /// <inheritdoc /> + public virtual object AddTimeout (TimeSpan time, Func<bool> callback) + { + if (Application.MainLoop is null) + { + throw new NotInitializedException ("Cannot add timeout before main loop is initialized", null); + } + + return Application.MainLoop.TimedEvents.AddTimeout (time, callback); + } + + /// <inheritdoc /> + public virtual bool RemoveTimeout (object token) + { + return Application.MainLoop?.TimedEvents.RemoveTimeout (token) ?? false; + } +} diff --git a/Terminal.Gui/Application/IApplication.cs b/Terminal.Gui/Application/IApplication.cs new file mode 100644 index 0000000000..bdd51046fe --- /dev/null +++ b/Terminal.Gui/Application/IApplication.cs @@ -0,0 +1,185 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; + +namespace Terminal.Gui; + +/// <summary> +/// Interface for instances that provide backing functionality to static +/// gateway class <see cref="Application"/>. +/// </summary> +public interface IApplication +{ + /// <summary>Initializes a new instance of <see cref="Terminal.Gui"/> Application.</summary> + /// <para>Call this method once per instance (or after <see cref="Shutdown"/> has been called).</para> + /// <para> + /// This function loads the right <see cref="IConsoleDriver"/> for the platform, Creates a <see cref="Toplevel"/>. and + /// assigns it to <see cref="Application.Top"/> + /// </para> + /// <para> + /// <see cref="Shutdown"/> must be called when the application is closing (typically after + /// <see cref="Run{T}"/> has returned) to ensure resources are cleaned up and + /// terminal settings + /// restored. + /// </para> + /// <para> + /// The <see cref="Run{T}"/> function combines + /// <see cref="Init(Terminal.Gui.IConsoleDriver,string)"/> and <see cref="Run(Toplevel, Func{Exception, bool})"/> + /// into a single + /// call. An application cam use <see cref="Run{T}"/> without explicitly calling + /// <see cref="Init(Terminal.Gui.IConsoleDriver,string)"/>. + /// </para> + /// <param name="driver"> + /// The <see cref="IConsoleDriver"/> to use. If neither <paramref name="driver"/> or + /// <paramref name="driverName"/> are specified the default driver for the platform will be used. + /// </param> + /// <param name="driverName"> + /// The short name (e.g. "net", "windows", "ansi", "fake", or "curses") of the + /// <see cref="IConsoleDriver"/> to use. If neither <paramref name="driver"/> or <paramref name="driverName"/> are + /// specified the default driver for the platform will be used. + /// </param> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public void Init (IConsoleDriver? driver = null, string? driverName = null); + + + /// <summary> + /// Runs the application by creating a <see cref="Toplevel"/> object and calling + /// <see cref="Run(Toplevel, Func{Exception, bool})"/>. + /// </summary> + /// <remarks> + /// <para>Calling <see cref="Init"/> first is not needed as this function will initialize the application.</para> + /// <para> + /// <see cref="Shutdown"/> must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// </para> + /// <para> + /// The caller is responsible for disposing the object returned by this method. + /// </para> + /// </remarks> + /// <returns>The created <see cref="Toplevel"/> object. The caller is responsible for disposing this object.</returns> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public Toplevel Run (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null); + + /// <summary> + /// Runs the application by creating a <see cref="Toplevel"/>-derived object of type <c>T</c> and calling + /// <see cref="Run(Toplevel, Func{Exception, bool})"/>. + /// </summary> + /// <remarks> + /// <para>Calling <see cref="Init"/> first is not needed as this function will initialize the application.</para> + /// <para> + /// <see cref="Shutdown"/> must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// </para> + /// <para> + /// The caller is responsible for disposing the object returned by this method. + /// </para> + /// </remarks> + /// <param name="errorHandler"></param> + /// <param name="driver"> + /// The <see cref="IConsoleDriver"/> to use. If not specified the default driver for the platform will + /// be used ( <see cref="WindowsDriver"/>, <see cref="CursesDriver"/>, or <see cref="NetDriver"/>). Must be + /// <see langword="null"/> if <see cref="Init"/> has already been called. + /// </param> + /// <returns>The created T object. The caller is responsible for disposing this object.</returns> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public T Run<T> (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) + where T : Toplevel, new (); + + /// <summary>Runs the Application using the provided <see cref="Toplevel"/> view.</summary> + /// <remarks> + /// <para> + /// This method is used to start processing events for the main application, but it is also used to run other + /// modal <see cref="View"/>s such as <see cref="Dialog"/> boxes. + /// </para> + /// <para> + /// To make a <see cref="Run(Terminal.Gui.Toplevel,System.Func{System.Exception,bool})"/> stop execution, call + /// <see cref="Application.RequestStop"/>. + /// </para> + /// <para> + /// Calling <see cref="Run(Terminal.Gui.Toplevel,System.Func{System.Exception,bool})"/> is equivalent to calling + /// <see cref="Application.Begin(Toplevel)"/>, followed by <see cref="Application.RunLoop(RunState)"/>, and then calling + /// <see cref="Application.End(RunState)"/>. + /// </para> + /// <para> + /// Alternatively, to have a program control the main loop and process events manually, call + /// <see cref="Application.Begin(Toplevel)"/> to set things up manually and then repeatedly call + /// <see cref="Application.RunLoop(RunState)"/> with the wait parameter set to false. By doing this the + /// <see cref="Application.RunLoop(RunState)"/> method will only process any pending events, timers, idle handlers and then + /// return control immediately. + /// </para> + /// <para>When using <see cref="Run{T}"/> or + /// <see cref="Run(System.Func{System.Exception,bool},Terminal.Gui.IConsoleDriver)"/> + /// <see cref="Init"/> will be called automatically. + /// </para> + /// <para> + /// RELEASE builds only: When <paramref name="errorHandler"/> is <see langword="null"/> any exceptions will be + /// rethrown. Otherwise, if <paramref name="errorHandler"/> will be called. If <paramref name="errorHandler"/> + /// returns <see langword="true"/> the <see cref="Application.RunLoop(RunState)"/> will resume; otherwise this method will + /// exit. + /// </para> + /// </remarks> + /// <param name="view">The <see cref="Toplevel"/> to run as a modal.</param> + /// <param name="errorHandler"> + /// RELEASE builds only: Handler for any unhandled exceptions (resumes when returns true, + /// rethrows when null). + /// </param> + public void Run (Toplevel view, Func<Exception, bool>? errorHandler = null); + + /// <summary>Shutdown an application initialized with <see cref="Init"/>.</summary> + /// <remarks> + /// Shutdown must be called for every call to <see cref="Init"/> or + /// <see cref="Application.Run(Toplevel, Func{Exception, bool})"/> to ensure all resources are cleaned + /// up (Disposed) + /// and terminal settings are restored. + /// </remarks> + public void Shutdown (); + + /// <summary>Stops the provided <see cref="Toplevel"/>, causing or the <paramref name="top"/> if provided.</summary> + /// <param name="top">The <see cref="Toplevel"/> to stop.</param> + /// <remarks> + /// <para>This will cause <see cref="Application.Run(Toplevel, Func{Exception, bool})"/> to return.</para> + /// <para> + /// Calling <see cref="RequestStop(Terminal.Gui.Toplevel)"/> is equivalent to setting the <see cref="Toplevel.Running"/> + /// property on the currently running <see cref="Toplevel"/> to false. + /// </para> + /// </remarks> + void RequestStop (Toplevel? top); + + /// <summary>Runs <paramref name="action"/> on the main UI loop thread</summary> + /// <param name="action">the action to be invoked on the main processing thread.</param> + void Invoke (Action action); + + /// <summary> + /// <see langword="true"/> if implementation is 'old'. <see langword="false"/> if implementation + /// is cutting edge. + /// </summary> + bool IsLegacy { get; } + + /// <summary> + /// 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. + /// </summary> + void AddIdle (Func<bool> func); + + /// <summary>Adds a timeout to the application.</summary> + /// <remarks> + /// When time specified passes, the callback will be invoked. If the callback returns true, the timeout will be + /// reset, repeating the invocation. If it returns false, the timeout will stop and be removed. The returned value is a + /// token that can be used to stop the timeout by calling <see cref="RemoveTimeout(object)"/>. + /// </remarks> + object AddTimeout (TimeSpan time, Func<bool> callback); + + /// <summary>Removes a previously scheduled timeout</summary> + /// <remarks>The token parameter is the value returned by <see cref="AddTimeout"/>.</remarks> + /// <returns> + /// <see langword="true"/> + /// if the timeout is successfully removed; otherwise, + /// <see langword="false"/> + /// . + /// This method also returns + /// <see langword="false"/> + /// if the timeout is not found.</returns> + bool RemoveTimeout (object token); +} \ No newline at end of file diff --git a/Terminal.Gui/Application/ITimedEvents.cs b/Terminal.Gui/Application/ITimedEvents.cs new file mode 100644 index 0000000000..80a2769336 --- /dev/null +++ b/Terminal.Gui/Application/ITimedEvents.cs @@ -0,0 +1,90 @@ +#nullable enable +using System.Collections.ObjectModel; + +namespace Terminal.Gui; + +/// <summary> +/// Manages timers and idles +/// </summary> +public interface ITimedEvents +{ + /// <summary> + /// 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. + /// </summary> + /// <param name="idleHandler"></param> + void AddIdle (Func<bool> idleHandler); + + /// <summary> + /// Runs all idle hooks + /// </summary> + void LockAndRunIdles (); + + /// <summary> + /// Runs all timeouts that are due + /// </summary> + void LockAndRunTimers (); + + /// <summary> + /// Called from <see cref="IMainLoopDriver.EventsPending"/> to check if there are any outstanding timers or idle + /// handlers. + /// </summary> + /// <param name="waitTimeout"> + /// Returns the number of milliseconds remaining in the current timer (if any). Will be -1 if + /// there are no active timers. + /// </param> + /// <returns><see langword="true"/> if there is a timer or idle handler active.</returns> + bool CheckTimersAndIdleHandlers (out int waitTimeout); + + /// <summary>Adds a timeout to the application.</summary> + /// <remarks> + /// When time specified passes, the callback will be invoked. If the callback returns true, the timeout will be + /// reset, repeating the invocation. If it returns false, the timeout will stop and be removed. The returned value is a + /// token that can be used to stop the timeout by calling <see cref="RemoveTimeout(object)"/>. + /// </remarks> + object AddTimeout (TimeSpan time, Func<bool> callback); + + /// <summary>Removes a previously scheduled timeout</summary> + /// <remarks>The token parameter is the value returned by AddTimeout.</remarks> + /// <returns> + /// Returns + /// <see langword="true"/> + /// if the timeout is successfully removed; otherwise, + /// <see langword="false"/> + /// . + /// This method also returns + /// <see langword="false"/> + /// if the timeout is not found. + /// </returns> + bool RemoveTimeout (object token); + + /// <summary> + /// Returns all currently registered idles. May not include + /// actively executing idles. + /// </summary> + ReadOnlyCollection<Func<bool>> IdleHandlers { get;} + + /// <summary> + /// Returns the next planned execution time (key - UTC ticks) + /// for each timeout that is not actively executing. + /// </summary> + SortedList<long, Timeout> Timeouts { get; } + + + /// <summary>Removes an idle handler added with <see cref="AddIdle(Func{bool})"/> from processing.</summary> + /// <returns> + /// <see langword="true"/> + /// if the idle handler is successfully removed; otherwise, + /// <see langword="false"/> + /// . + /// This method also returns + /// <see langword="false"/> + /// if the idle handler is not found.</returns> + bool RemoveIdle (Func<bool> fnTrue); + + /// <summary> + /// Invoked when a new timeout is added. To be used in the case when + /// <see cref="Application.EndAfterFirstIteration"/> is <see langword="true"/>. + /// </summary> + event EventHandler<TimeoutEventArgs>? TimeoutAdded; +} diff --git a/Terminal.Gui/Application/MainLoop.cs b/Terminal.Gui/Application/MainLoop.cs index 1e6006923d..3b6cf3b9d3 100644 --- a/Terminal.Gui/Application/MainLoop.cs +++ b/Terminal.Gui/Application/MainLoop.cs @@ -14,7 +14,7 @@ namespace Terminal.Gui; internal interface IMainLoopDriver { /// <summary>Must report whether there are any events pending, or even block waiting for events.</summary> - /// <returns><c>true</c>, if there were pending events, <c>false</c> otherwise.</returns> + /// <returns><see langword="true"/>, if there were pending events, <see langword="false"/> otherwise.</returns> bool EventsPending (); /// <summary>The iteration function.</summary> @@ -39,13 +39,10 @@ internal interface IMainLoopDriver /// </remarks> public class MainLoop : IDisposable { - internal List<Func<bool>> _idleHandlers = new (); - internal SortedList<long, Timeout> _timeouts = new (); - - /// <summary>The idle handlers and lock that must be held while manipulating them</summary> - private readonly object _idleHandlersLock = new (); - - private readonly object _timeoutsLockToken = new (); + /// <summary> + /// Gets the class responsible for handling idles and timeouts + /// </summary> + public ITimedEvents TimedEvents { get; } = new TimedEvents(); /// <summary>Creates a new MainLoop.</summary> /// <remarks>Use <see cref="Dispose"/> to release resources.</remarks> @@ -59,17 +56,6 @@ internal MainLoop (IMainLoopDriver driver) driver.Setup (this); } - /// <summary>Gets a copy of the list of all idle handlers.</summary> - internal ReadOnlyCollection<Func<bool>> IdleHandlers - { - get - { - lock (_idleHandlersLock) - { - return new List<Func<bool>> (_idleHandlers).AsReadOnly (); - } - } - } /// <summary>The current <see cref="IMainLoopDriver"/> in use.</summary> /// <value>The main loop driver.</value> @@ -78,11 +64,6 @@ internal ReadOnlyCollection<Func<bool>> IdleHandlers /// <summary>Used for unit tests.</summary> internal bool Running { get; set; } - /// <summary> - /// Gets the list of all timeouts sorted by the <see cref="TimeSpan"/> time ticks. A shorter limit time can be - /// added at the end, but it will be called before an earlier addition that has a longer limit time. - /// </summary> - internal SortedList<long, Timeout> Timeouts => _timeouts; /// <inheritdoc/> public void Dispose () @@ -99,13 +80,13 @@ public void Dispose () /// once per iteration of the main loop after other events have been handled. /// </summary> /// <remarks> - /// <para>Remove an idle handler by calling <see cref="RemoveIdle(Func{bool})"/> with the token this method returns.</para> + /// <para>Remove an idle handler by calling <see cref="TimedEvents.RemoveIdle(Func{bool})"/> with the token this method returns.</para> /// <para> /// If the <paramref name="idleHandler"/> returns <see langword="false"/> it will be removed and not called /// subsequently. /// </para> /// </remarks> - /// <param name="idleHandler">Token that can be used to remove the idle handler with <see cref="RemoveIdle(Func{bool})"/> .</param> + /// <param name="idleHandler">Token that can be used to remove the idle handler with <see cref="TimedEvents.RemoveIdle(Func{bool})"/> .</param> // QUESTION: Why are we re-inventing the event wheel here? // PERF: This is heavy. // CONCURRENCY: Race conditions exist here. @@ -113,76 +94,13 @@ public void Dispose () // internal Func<bool> AddIdle (Func<bool> idleHandler) { - lock (_idleHandlersLock) - { - _idleHandlers.Add (idleHandler); - } + TimedEvents.AddIdle (idleHandler); MainLoopDriver?.Wakeup (); return idleHandler; } - /// <summary>Adds a timeout to the <see cref="MainLoop"/>.</summary> - /// <remarks> - /// When time specified passes, the callback will be invoked. If the callback returns true, the timeout will be - /// reset, repeating the invocation. If it returns false, the timeout will stop and be removed. The returned value is a - /// token that can be used to stop the timeout by calling <see cref="RemoveTimeout(object)"/>. - /// </remarks> - internal object AddTimeout (TimeSpan time, Func<bool> callback) - { - ArgumentNullException.ThrowIfNull (callback); - - var timeout = new Timeout { Span = time, Callback = callback }; - AddTimeout (time, timeout); - - return timeout; - } - - /// <summary> - /// Called from <see cref="IMainLoopDriver.EventsPending"/> to check if there are any outstanding timers or idle - /// handlers. - /// </summary> - /// <param name="waitTimeout"> - /// Returns the number of milliseconds remaining in the current timer (if any). Will be -1 if - /// there are no active timers. - /// </param> - /// <returns><see langword="true"/> if there is a timer or idle handler active.</returns> - internal bool CheckTimersAndIdleHandlers (out int waitTimeout) - { - long now = DateTime.UtcNow.Ticks; - - waitTimeout = 0; - - lock (_timeoutsLockToken) - { - if (_timeouts.Count > 0) - { - waitTimeout = (int)((_timeouts.Keys [0] - now) / TimeSpan.TicksPerMillisecond); - - if (waitTimeout < 0) - { - // This avoids 'poll' waiting infinitely if 'waitTimeout < 0' until some action is detected - // This can occur after IMainLoopDriver.Wakeup is executed where the pollTimeout is less than 0 - // and no event occurred in elapsed time when the 'poll' is start running again. - waitTimeout = 0; - } - - return true; - } - - // ManualResetEventSlim.Wait, which is called by IMainLoopDriver.EventsPending, will wait indefinitely if - // the timeout is -1. - waitTimeout = -1; - } - - // There are no timers set, check if there are any idle handlers - - lock (_idleHandlers) - { - return _idleHandlers.Count > 0; - } - } /// <summary>Determines whether there are pending events to be processed.</summary> /// <remarks> @@ -191,50 +109,6 @@ internal bool CheckTimersAndIdleHandlers (out int waitTimeout) /// </remarks> internal bool EventsPending () { return MainLoopDriver!.EventsPending (); } - /// <summary>Removes an idle handler added with <see cref="AddIdle(Func{bool})"/> from processing.</summary> - /// <param name="token">A token returned by <see cref="AddIdle(Func{bool})"/></param> - /// Returns - /// <c>true</c> - /// if the idle handler is successfully removed; otherwise, - /// <c>false</c> - /// . - /// This method also returns - /// <c>false</c> - /// if the idle handler is not found. - internal bool RemoveIdle (Func<bool> token) - { - lock (_idleHandlersLock) - { - return _idleHandlers.Remove (token); - } - } - - /// <summary>Removes a previously scheduled timeout</summary> - /// <remarks>The token parameter is the value returned by AddTimeout.</remarks> - /// Returns - /// <c>true</c> - /// if the timeout is successfully removed; otherwise, - /// <c>false</c> - /// . - /// This method also returns - /// <c>false</c> - /// if the timeout is not found. - internal bool RemoveTimeout (object token) - { - lock (_timeoutsLockToken) - { - int idx = _timeouts.IndexOfValue ((token as Timeout)!); - - if (idx == -1) - { - return false; - } - - _timeouts.RemoveAt (idx); - } - - return true; - } /// <summary>Runs the <see cref="MainLoop"/>. Used only for unit tests.</summary> internal void Run () @@ -260,29 +134,13 @@ internal void Run () /// </remarks> internal void RunIteration () { - lock (_timeoutsLockToken) - { - if (_timeouts.Count > 0) - { - RunTimers (); - } - } - RunAnsiScheduler (); MainLoopDriver?.Iteration (); - bool runIdle; + TimedEvents.LockAndRunTimers (); - lock (_idleHandlersLock) - { - runIdle = _idleHandlers.Count > 0; - } - - if (runIdle) - { - RunIdle (); - } + TimedEvents.LockAndRunIdles (); } private void RunAnsiScheduler () @@ -297,101 +155,9 @@ internal void Stop () Wakeup (); } - /// <summary> - /// Invoked when a new timeout is added. To be used in the case when - /// <see cref="Application.EndAfterFirstIteration"/> is <see langword="true"/>. - /// </summary> - internal event EventHandler<TimeoutEventArgs>? TimeoutAdded; /// <summary>Wakes up the <see cref="MainLoop"/> that might be waiting on input.</summary> internal void Wakeup () { MainLoopDriver?.Wakeup (); } - private void AddTimeout (TimeSpan time, Timeout timeout) - { - lock (_timeoutsLockToken) - { - long k = (DateTime.UtcNow + time).Ticks; - _timeouts.Add (NudgeToUniqueKey (k), timeout); - TimeoutAdded?.Invoke (this, new TimeoutEventArgs (timeout, k)); - } - } - /// <summary> - /// Finds the closest number to <paramref name="k"/> that is not present in <see cref="_timeouts"/> - /// (incrementally). - /// </summary> - /// <param name="k"></param> - /// <returns></returns> - private long NudgeToUniqueKey (long k) - { - lock (_timeoutsLockToken) - { - while (_timeouts.ContainsKey (k)) - { - k++; - } - } - - return k; - } - - // PERF: This is heavier than it looks. - // CONCURRENCY: Potential deadlock city here. - // CONCURRENCY: Multiple concurrency pitfalls on the delegates themselves. - // INTENT: It looks like the general architecture here is trying to be a form of publisher/consumer pattern. - private void RunIdle () - { - List<Func<bool>> iterate; - - lock (_idleHandlersLock) - { - iterate = _idleHandlers; - _idleHandlers = new List<Func<bool>> (); - } - - foreach (Func<bool> idle in iterate) - { - if (idle ()) - { - lock (_idleHandlersLock) - { - _idleHandlers.Add (idle); - } - } - } - } - - private void RunTimers () - { - long now = DateTime.UtcNow.Ticks; - SortedList<long, Timeout> copy; - - // lock prevents new timeouts being added - // after we have taken the copy but before - // we have allocated a new list (which would - // result in lost timeouts or errors during enumeration) - lock (_timeoutsLockToken) - { - copy = _timeouts; - _timeouts = new SortedList<long, Timeout> (); - } - - foreach ((long k, Timeout timeout) in copy) - { - if (k < now) - { - if (timeout.Callback ()) - { - AddTimeout (timeout.Span, timeout); - } - } - else - { - lock (_timeoutsLockToken) - { - _timeouts.Add (NudgeToUniqueKey (k), timeout); - } - } - } - } } diff --git a/Terminal.Gui/Application/TimedEvents.cs b/Terminal.Gui/Application/TimedEvents.cs new file mode 100644 index 0000000000..8325e6ed6e --- /dev/null +++ b/Terminal.Gui/Application/TimedEvents.cs @@ -0,0 +1,257 @@ +#nullable enable +using System.Collections.ObjectModel; + +namespace Terminal.Gui; + +/// <summary> +/// Handles timeouts and idles +/// </summary> +public class TimedEvents : ITimedEvents +{ + internal List<Func<bool>> _idleHandlers = new (); + internal SortedList<long, Timeout> _timeouts = new (); + + /// <summary>The idle handlers and lock that must be held while manipulating them</summary> + private readonly object _idleHandlersLock = new (); + + private readonly object _timeoutsLockToken = new (); + + + /// <summary>Gets a copy of the list of all idle handlers.</summary> + public ReadOnlyCollection<Func<bool>> IdleHandlers + { + get + { + lock (_idleHandlersLock) + { + return new List<Func<bool>> (_idleHandlers).AsReadOnly (); + } + } + } + + /// <summary> + /// Gets the list of all timeouts sorted by the <see cref="TimeSpan"/> time ticks. A shorter limit time can be + /// added at the end, but it will be called before an earlier addition that has a longer limit time. + /// </summary> + public SortedList<long, Timeout> Timeouts => _timeouts; + + /// <inheritdoc /> + public void AddIdle (Func<bool> idleHandler) + { + lock (_idleHandlersLock) + { + _idleHandlers.Add (idleHandler); + } + } + + /// <inheritdoc/> + public event EventHandler<TimeoutEventArgs>? TimeoutAdded; + + + private void AddTimeout (TimeSpan time, Timeout timeout) + { + lock (_timeoutsLockToken) + { + long k = (DateTime.UtcNow + time).Ticks; + _timeouts.Add (NudgeToUniqueKey (k), timeout); + TimeoutAdded?.Invoke (this, new TimeoutEventArgs (timeout, k)); + } + } + + /// <summary> + /// Finds the closest number to <paramref name="k"/> that is not present in <see cref="_timeouts"/> + /// (incrementally). + /// </summary> + /// <param name="k"></param> + /// <returns></returns> + private long NudgeToUniqueKey (long k) + { + lock (_timeoutsLockToken) + { + while (_timeouts.ContainsKey (k)) + { + k++; + } + } + + return k; + } + + + // PERF: This is heavier than it looks. + // CONCURRENCY: Potential deadlock city here. + // CONCURRENCY: Multiple concurrency pitfalls on the delegates themselves. + // INTENT: It looks like the general architecture here is trying to be a form of publisher/consumer pattern. + private void RunIdle () + { + Func<bool> [] iterate; + lock (_idleHandlersLock) + { + iterate = _idleHandlers.ToArray (); + _idleHandlers = new List<Func<bool>> (); + } + + foreach (Func<bool> idle in iterate) + { + if (idle ()) + { + lock (_idleHandlersLock) + { + _idleHandlers.Add (idle); + } + } + } + } + + /// <inheritdoc/> + public void LockAndRunTimers () + { + lock (_timeoutsLockToken) + { + if (_timeouts.Count > 0) + { + RunTimers (); + } + } + + } + + /// <inheritdoc/> + public void LockAndRunIdles () + { + bool runIdle; + + lock (_idleHandlersLock) + { + runIdle = _idleHandlers.Count > 0; + } + + if (runIdle) + { + RunIdle (); + } + } + private void RunTimers () + { + long now = DateTime.UtcNow.Ticks; + SortedList<long, Timeout> copy; + + // lock prevents new timeouts being added + // after we have taken the copy but before + // we have allocated a new list (which would + // result in lost timeouts or errors during enumeration) + lock (_timeoutsLockToken) + { + copy = _timeouts; + _timeouts = new SortedList<long, Timeout> (); + } + + foreach ((long k, Timeout timeout) in copy) + { + if (k < now) + { + if (timeout.Callback ()) + { + AddTimeout (timeout.Span, timeout); + } + } + else + { + lock (_timeoutsLockToken) + { + _timeouts.Add (NudgeToUniqueKey (k), timeout); + } + } + } + } + + /// <inheritdoc/> + public bool RemoveIdle (Func<bool> token) + { + lock (_idleHandlersLock) + { + return _idleHandlers.Remove (token); + } + } + + /// <summary>Removes a previously scheduled timeout</summary> + /// <remarks>The token parameter is the value returned by AddTimeout.</remarks> + /// Returns + /// <see langword="true"/> + /// if the timeout is successfully removed; otherwise, + /// <see langword="false"/> + /// . + /// This method also returns + /// <see langword="false"/> + /// if the timeout is not found. + public bool RemoveTimeout (object token) + { + lock (_timeoutsLockToken) + { + int idx = _timeouts.IndexOfValue ((token as Timeout)!); + + if (idx == -1) + { + return false; + } + + _timeouts.RemoveAt (idx); + } + + return true; + } + + + /// <summary>Adds a timeout to the <see cref="MainLoop"/>.</summary> + /// <remarks> + /// When time specified passes, the callback will be invoked. If the callback returns true, the timeout will be + /// reset, repeating the invocation. If it returns false, the timeout will stop and be removed. The returned value is a + /// token that can be used to stop the timeout by calling <see cref="RemoveTimeout(object)"/>. + /// </remarks> + public object AddTimeout (TimeSpan time, Func<bool> callback) + { + ArgumentNullException.ThrowIfNull (callback); + + var timeout = new Timeout { Span = time, Callback = callback }; + AddTimeout (time, timeout); + + return timeout; + } + + /// <inheritdoc/> + public bool CheckTimersAndIdleHandlers (out int waitTimeout) + { + long now = DateTime.UtcNow.Ticks; + + waitTimeout = 0; + + lock (_timeoutsLockToken) + { + if (_timeouts.Count > 0) + { + waitTimeout = (int)((_timeouts.Keys [0] - now) / TimeSpan.TicksPerMillisecond); + + if (waitTimeout < 0) + { + // This avoids 'poll' waiting infinitely if 'waitTimeout < 0' until some action is detected + // This can occur after IMainLoopDriver.Wakeup is executed where the pollTimeout is less than 0 + // and no event occurred in elapsed time when the 'poll' is start running again. + waitTimeout = 0; + } + + return true; + } + + // ManualResetEventSlim.Wait, which is called by IMainLoopDriver.EventsPending, will wait indefinitely if + // the timeout is -1. + waitTimeout = -1; + } + + // There are no timers set, check if there are any idle handlers + + lock (_idleHandlersLock) + { + return _idleHandlers.Count > 0; + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Application/TimeoutEventArgs.cs b/Terminal.Gui/Application/TimeoutEventArgs.cs index 19346e57f1..2e01228c19 100644 --- a/Terminal.Gui/Application/TimeoutEventArgs.cs +++ b/Terminal.Gui/Application/TimeoutEventArgs.cs @@ -1,7 +1,7 @@ namespace Terminal.Gui; -/// <summary><see cref="EventArgs"/> for timeout events (e.g. <see cref="MainLoop.TimeoutAdded"/>)</summary> -internal class TimeoutEventArgs : EventArgs +/// <summary><see cref="EventArgs"/> for timeout events (e.g. <see cref="TimedEvents.TimeoutAdded"/>)</summary> +public class TimeoutEventArgs : EventArgs { /// <summary>Creates a new instance of the <see cref="TimeoutEventArgs"/> class.</summary> /// <param name="timeout"></param> diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs new file mode 100644 index 0000000000..5ae29060bf --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs @@ -0,0 +1,267 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// <summary> +/// Parses mouse ansi escape sequences into <see cref="MouseEventArgs"/> +/// including support for pressed, released and mouse wheel. +/// </summary> +public class AnsiMouseParser +{ + // Regex patterns for button press/release, wheel scroll, and mouse position reporting + private readonly Regex _mouseEventPattern = new (@"\u001b\[<(\d+);(\d+);(\d+)(M|m)", RegexOptions.Compiled); + + /// <summary> + /// Returns true if it is a mouse event + /// </summary> + /// <param name="cur"></param> + /// <returns></returns> + public bool IsMouse (string cur) + { + // Typically in this format + // ESC [ < {button_code};{x_pos};{y_pos}{final_byte} + return cur.EndsWith ('M') || cur.EndsWith ('m'); + } + + /// <summary> + /// Parses a mouse ansi escape sequence into a mouse event. Returns null if input + /// is not a mouse event or its syntax is not understood. + /// </summary> + /// <param name="input"></param> + /// <returns></returns> + public MouseEventArgs? ProcessMouseInput (string input) + { + // Match mouse wheel events first + Match match = _mouseEventPattern.Match (input); + + if (match.Success) + { + int buttonCode = int.Parse (match.Groups [1].Value); + + // The top-left corner of the terminal corresponds to (1, 1) for both X (column) and Y (row) coordinates. + // ANSI standards and terminal conventions historically treat screen positions as 1 - based. + + int x = int.Parse (match.Groups [2].Value) - 1; + int y = int.Parse (match.Groups [3].Value) - 1; + char terminator = match.Groups [4].Value.Single (); + + var m = new MouseEventArgs + { + Position = new (x, y), + Flags = GetFlags (buttonCode, terminator) + }; + + Logging.Trace ($"{nameof (AnsiMouseParser)} handled as {input} mouse {m.Flags} at {m.Position}"); + + return m; + } + + // its some kind of odd mouse event that doesn't follow expected format? + return null; + } + + private static MouseFlags GetFlags (int buttonCode, char terminator) + { + MouseFlags buttonState = 0; + + switch (buttonCode) + { + case 0: + case 8: + case 16: + case 24: + case 32: + case 36: + case 40: + case 48: + case 56: + buttonState = terminator == 'M' + ? MouseFlags.Button1Pressed + : MouseFlags.Button1Released; + + break; + case 1: + case 9: + case 17: + case 25: + case 33: + case 37: + case 41: + case 45: + case 49: + case 53: + case 57: + case 61: + buttonState = terminator == 'M' + ? MouseFlags.Button2Pressed + : MouseFlags.Button2Released; + + break; + case 2: + case 10: + case 14: + case 18: + case 22: + case 26: + case 30: + case 34: + case 42: + case 46: + case 50: + case 54: + case 58: + case 62: + buttonState = terminator == 'M' + ? MouseFlags.Button3Pressed + : MouseFlags.Button3Released; + + break; + case 35: + //// Needed for Windows OS + //if (isButtonPressed && c == 'm' + // && (lastMouseEvent.ButtonState == MouseFlags.Button1Pressed + // || lastMouseEvent.ButtonState == MouseFlags.Button2Pressed + // || lastMouseEvent.ButtonState == MouseFlags.Button3Pressed)) { + + // switch (lastMouseEvent.ButtonState) { + // case MouseFlags.Button1Pressed: + // buttonState = MouseFlags.Button1Released; + // break; + // case MouseFlags.Button2Pressed: + // buttonState = MouseFlags.Button2Released; + // break; + // case MouseFlags.Button3Pressed: + // buttonState = MouseFlags.Button3Released; + // break; + // } + //} else { + // buttonState = MouseFlags.ReportMousePosition; + //} + //break; + case 39: + case 43: + case 47: + case 51: + case 55: + case 59: + case 63: + buttonState = MouseFlags.ReportMousePosition; + + break; + case 64: + buttonState = MouseFlags.WheeledUp; + + break; + case 65: + buttonState = MouseFlags.WheeledDown; + + break; + case 68: + case 72: + case 80: + buttonState = MouseFlags.WheeledLeft; // Shift/Ctrl+WheeledUp + + break; + case 69: + case 73: + case 81: + buttonState = MouseFlags.WheeledRight; // Shift/Ctrl+WheeledDown + + break; + } + + // Modifiers. + switch (buttonCode) + { + case 8: + case 9: + case 10: + case 43: + buttonState |= MouseFlags.ButtonAlt; + + break; + case 14: + case 47: + buttonState |= MouseFlags.ButtonAlt | MouseFlags.ButtonShift; + + break; + case 16: + case 17: + case 18: + case 51: + buttonState |= MouseFlags.ButtonCtrl; + + break; + case 22: + case 55: + buttonState |= MouseFlags.ButtonCtrl | MouseFlags.ButtonShift; + + break; + case 24: + case 25: + case 26: + case 59: + buttonState |= MouseFlags.ButtonCtrl | MouseFlags.ButtonAlt; + + break; + case 30: + case 63: + buttonState |= MouseFlags.ButtonCtrl | MouseFlags.ButtonShift | MouseFlags.ButtonAlt; + + break; + case 32: + case 33: + case 34: + buttonState |= MouseFlags.ReportMousePosition; + + break; + case 36: + case 37: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonShift; + + break; + case 39: + case 68: + case 69: + buttonState |= MouseFlags.ButtonShift; + + break; + case 40: + case 41: + case 42: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonAlt; + + break; + case 45: + case 46: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonAlt | MouseFlags.ButtonShift; + + break; + case 48: + case 49: + case 50: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonCtrl; + + break; + case 53: + case 54: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonCtrl | MouseFlags.ButtonShift; + + break; + case 56: + case 57: + case 58: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonCtrl | MouseFlags.ButtonAlt; + + break; + case 61: + case 62: + buttonState |= MouseFlags.ReportMousePosition | MouseFlags.ButtonCtrl | MouseFlags.ButtonShift | MouseFlags.ButtonAlt; + + break; + } + + return buttonState; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs index d2a7841c44..aec693de55 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiRequestScheduler.cs @@ -68,7 +68,7 @@ public AnsiRequestScheduler (IAnsiResponseParser parser, Func<DateTime>? now = n /// <summary> /// Sends the <paramref name="request"/> immediately or queues it if there is already - /// an outstanding request for the given <see cref="AnsiEscapeSequenceRequest.Terminator"/>. + /// an outstanding request for the given <see cref="AnsiEscapeSequence.Terminator"/>. /// </summary> /// <param name="request"></param> /// <returns><see langword="true"/> if request was sent immediately. <see langword="false"/> if it was queued.</returns> @@ -213,4 +213,4 @@ private bool ShouldThrottle (AnsiEscapeSequenceRequest r) return false; } -} \ No newline at end of file +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs index 503a637695..93d2bbeb9a 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs @@ -1,13 +1,40 @@ #nullable enable +using Microsoft.Extensions.Logging; + namespace Terminal.Gui; internal abstract class AnsiResponseParserBase : IAnsiResponseParser { + private const char Escape = '\x1B'; + private readonly AnsiMouseParser _mouseParser = new (); + protected readonly AnsiKeyboardParser _keyboardParser = new (); protected object _lockExpectedResponses = new (); protected object _lockState = new (); + /// <summary> + /// Event raised when mouse events are detected - requires setting <see cref="HandleMouse"/> to true + /// </summary> + public event EventHandler<MouseEventArgs>? Mouse; + + /// <summary> + /// Event raised when keyboard event is detected (e.g. cursors) - requires setting <see cref="HandleKeyboard"/> + /// </summary> + public event EventHandler<Key>? Keyboard; + + /// <summary> + /// True to explicitly handle mouse escape sequences by passing them to <see cref="Mouse"/> event. + /// Defaults to <see langword="false"/> + /// </summary> + public bool HandleMouse { get; set; } = false; + + /// <summary> + /// True to explicitly handle keyboard escape sequences (such as cursor keys) by passing them to <see cref="Keyboard"/> + /// event + /// </summary> + public bool HandleKeyboard { get; set; } = false; + /// <summary> /// Responses we are expecting to come in. /// </summary> @@ -110,7 +137,7 @@ int inputLength char currentChar = getCharAtIndex (index); object currentObj = getObjectAtIndex (index); - bool isEscape = currentChar == '\x1B'; + bool isEscape = currentChar == Escape; switch (State) { @@ -118,7 +145,7 @@ int inputLength if (isEscape) { // Escape character detected, move to ExpectingBracket state - State = AnsiResponseParserState.ExpectingBracket; + State = AnsiResponseParserState.ExpectingEscapeSequence; _heldContent.AddToHeld (currentObj); // Hold the escape character } else @@ -129,18 +156,22 @@ int inputLength break; - case AnsiResponseParserState.ExpectingBracket: + case AnsiResponseParserState.ExpectingEscapeSequence: if (isEscape) { // Second escape so we must release first - ReleaseHeld (appendOutput, AnsiResponseParserState.ExpectingBracket); + ReleaseHeld (appendOutput, AnsiResponseParserState.ExpectingEscapeSequence); _heldContent.AddToHeld (currentObj); // Hold the new escape } - else if (currentChar == '[') + else if (_heldContent.Length == 1) { - // Detected '[', transition to InResponse state + //We need O for SS3 mode F1-F4 e.g. "<esc>OP" => F1 + //We need any letter or digit for Alt+Letter (see EscAsAltPattern) + //In fact lets just always see what comes after esc + + // Detected '[' or 'O', transition to InResponse state State = AnsiResponseParserState.InResponse; - _heldContent.AddToHeld (currentObj); // Hold the '[' + _heldContent.AddToHeld (currentObj); // Hold the letter } else { @@ -152,12 +183,24 @@ int inputLength break; case AnsiResponseParserState.InResponse: - _heldContent.AddToHeld (currentObj); - // Check if the held content should be released - if (ShouldReleaseHeldContent ()) + // if seeing another esc, we must resolve the current one first + if (isEscape) { ReleaseHeld (appendOutput); + State = AnsiResponseParserState.ExpectingEscapeSequence; + _heldContent.AddToHeld (currentObj); + } + else + { + // Non esc, so continue to build sequence + _heldContent.AddToHeld (currentObj); + + // Check if the held content should be released + if (ShouldReleaseHeldContent ()) + { + ReleaseHeld (appendOutput); + } } break; @@ -169,6 +212,8 @@ int inputLength private void ReleaseHeld (Action<object> appendOutput, AnsiResponseParserState newState = AnsiResponseParserState.Normal) { + TryLastMinuteSequences (); + foreach (object o in _heldContent.HeldToObjects ()) { appendOutput (o); @@ -178,6 +223,48 @@ private void ReleaseHeld (Action<object> appendOutput, AnsiResponseParserState n _heldContent.ClearHeld (); } + /// <summary> + /// Checks current held chars against any sequences that have + /// conflicts with longer sequences e.g. Esc as Alt sequences + /// which can conflict if resolved earlier e.g. with EscOP ss3 + /// sequences. + /// </summary> + protected void TryLastMinuteSequences () + { + lock (_lockState) + { + string cur = _heldContent.HeldToString (); + + if (HandleKeyboard) + { + AnsiKeyboardParserPattern? pattern = _keyboardParser.IsKeyboard (cur, true); + + if (pattern != null) + { + RaiseKeyboardEvent (pattern, cur); + _heldContent.ClearHeld (); + + return; + } + } + + // We have something totally unexpected, not a CSI and + // still Esc+<something>. So give last minute swallow chance + if (cur.Length >= 2 && cur [0] == Escape) + { + // Maybe swallow anyway if user has custom delegate + bool swallow = ShouldSwallowUnexpectedResponse (); + + if (swallow) + { + _heldContent.ClearHeld (); + + Logging.Trace ($"AnsiResponseParser last minute swallowed '{cur}'"); + } + } + } + } + // Common response handler logic protected bool ShouldReleaseHeldContent () { @@ -185,6 +272,27 @@ protected bool ShouldReleaseHeldContent () { string cur = _heldContent.HeldToString (); + if (HandleMouse && IsMouse (cur)) + { + RaiseMouseEvent (cur); + ResetState (); + + return false; + } + + if (HandleKeyboard) + { + AnsiKeyboardParserPattern? pattern = _keyboardParser.IsKeyboard (cur); + + if (pattern != null) + { + RaiseKeyboardEvent (pattern, cur); + ResetState (); + + return false; + } + } + lock (_lockExpectedResponses) { // Look for an expected response for what is accumulated so far (since Esc) @@ -232,6 +340,8 @@ protected bool ShouldReleaseHeldContent () { _heldContent.ClearHeld (); + Logging.Trace ($"AnsiResponseParser swallowed '{cur}'"); + // Do not send back to input stream return false; } @@ -244,6 +354,32 @@ protected bool ShouldReleaseHeldContent () return false; // Continue accumulating } + private void RaiseMouseEvent (string cur) + { + MouseEventArgs? ev = _mouseParser.ProcessMouseInput (cur); + + if (ev != null) + { + Mouse?.Invoke (this, ev); + } + } + + private bool IsMouse (string cur) { return _mouseParser.IsMouse (cur); } + + protected void RaiseKeyboardEvent (AnsiKeyboardParserPattern pattern, string cur) + { + Key? k = pattern.GetKey (cur); + + if (k is null) + { + Logging.Logger.LogError ($"Failed to determine a Key for given Keyboard escape sequence '{cur}'"); + } + else + { + Keyboard?.Invoke (this, k); + } + } + /// <summary> /// <para> /// When overriden in a derived class, indicates whether the unexpected response @@ -265,6 +401,8 @@ private bool MatchResponse (string cur, List<AnsiResponseExpectation> collection if (matchingResponse?.Response != null) { + Logging.Trace ($"AnsiResponseParser processed '{cur}'"); + if (invokeCallback) { matchingResponse.Response.Invoke (_heldContent); @@ -339,8 +477,10 @@ public void StopExpecting (string terminator, bool persistent) } } -internal class AnsiResponseParser<T> () : AnsiResponseParserBase (new GenericHeld<T> ()) +internal class AnsiResponseParser<T> : AnsiResponseParserBase { + public AnsiResponseParser () : base (new GenericHeld<T> ()) { } + /// <inheritdoc cref="AnsiResponseParser.UnknownResponseHandler"/> public Func<IEnumerable<Tuple<char, T>>, bool> UnexpectedResponseHandler { get; set; } = _ => false; @@ -351,17 +491,27 @@ public IEnumerable<Tuple<char, T>> ProcessInput (params Tuple<char, T> [] input) ProcessInputBase ( i => input [i].Item1, i => input [i], - c => output.Add ((Tuple<char, T>)c), + c => AppendOutput (output, c), input.Length); return output; } + private void AppendOutput (List<Tuple<char, T>> output, object c) + { + Tuple<char, T> tuple = (Tuple<char, T>)c; + + Logging.Trace ($"AnsiResponseParser releasing '{tuple.Item1}'"); + output.Add (tuple); + } + public Tuple<char, T> [] Release () { // Lock in case Release is called from different Thread from parse lock (_lockState) { + TryLastMinuteSequences (); + Tuple<char, T> [] result = HeldToEnumerable ().ToArray (); ResetState (); @@ -421,16 +571,24 @@ public string ProcessInput (string input) ProcessInputBase ( i => input [i], i => input [i], // For string there is no T so object is same as char - c => output.Append ((char)c), + c => AppendOutput (output, (char)c), input.Length); return output.ToString (); } + private void AppendOutput (StringBuilder output, char c) + { + Logging.Trace ($"AnsiResponseParser releasing '{c}'"); + output.Append (c); + } + public string Release () { lock (_lockState) { + TryLastMinuteSequences (); + string output = _heldContent.HeldToString (); ResetState (); diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParserState.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParserState.cs index 934b6eb3eb..a28050f643 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParserState.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParserState.cs @@ -12,9 +12,10 @@ public enum AnsiResponseParserState /// <summary> /// Parser has encountered an Esc and is waiting to see if next - /// key(s) continue to form an Ansi escape sequence + /// key(s) continue to form an Ansi escape sequence (typically '[' but + /// also other characters e.g. O for SS3). /// </summary> - ExpectingBracket, + ExpectingEscapeSequence, /// <summary> /// Parser has encountered Esc[ and considers that it is in the process diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs index ffd8d46891..8a5955da40 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/GenericHeld.cs @@ -16,4 +16,7 @@ internal class GenericHeld<T> : IHeld public IEnumerable<object> HeldToObjects () { return held; } public void AddToHeld (object o) { held.Add ((Tuple<char, T>)o); } + + /// <inheritdoc/> + public int Length => held.Count; } diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs index dbcd16b954..f0424711b2 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IAnsiResponseParser.cs @@ -3,7 +3,7 @@ namespace Terminal.Gui; /// <summary> /// When implemented in a derived class, allows watching an input stream of characters -/// (i.e. console input) for ANSI response sequences. +/// (i.e. console input) for ANSI response sequences (mouse input, cursor, query responses etc.). /// </summary> public interface IAnsiResponseParser { diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs index ab23f477fd..1f0079dbed 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/IHeld.cs @@ -30,4 +30,6 @@ internal interface IHeld /// </summary> /// <param name="o"></param> void AddToHeld (object o); + + int Length { get; } } diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs new file mode 100644 index 0000000000..c0da8e023a --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParser.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Parses ANSI escape sequence strings that describe keyboard activity into <see cref="Key"/>. +/// </summary> +public class AnsiKeyboardParser +{ + private readonly List<AnsiKeyboardParserPattern> _patterns = new () + { + new Ss3Pattern (), + new CsiKeyPattern (), + new EscAsAltPattern { IsLastMinute = true } + }; + + /// <summary> + /// Looks for any pattern that matches the <paramref name="input"/> and returns + /// the matching pattern or <see langword="null"/> if no matches. + /// </summary> + /// <param name="input"></param> + /// <param name="isLastMinute"></param> + /// <returns></returns> + public AnsiKeyboardParserPattern? IsKeyboard (string input, bool isLastMinute = false) + { + return _patterns.FirstOrDefault (pattern => pattern.IsLastMinute == isLastMinute && pattern.IsMatch (input)); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParserPattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParserPattern.cs new file mode 100644 index 0000000000..52b6526853 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/AnsiKeyboardParserPattern.cs @@ -0,0 +1,55 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Base class for ANSI keyboard parsing patterns. +/// </summary> +public abstract class AnsiKeyboardParserPattern +{ + /// <summary> + /// Does this pattern dangerously overlap with other sequences + /// such that it should only be applied at the lsat second after + /// all other sequences have been tried. + /// <remarks> + /// When <see langword="true"/> this pattern will only be used + /// at <see cref="AnsiResponseParser.Release"/> time. + /// </remarks> + /// </summary> + public bool IsLastMinute { get; set; } + + /// <summary> + /// Returns <see langword="true"/> if <paramref name="input"/> is one + /// of the terminal sequences recognised by this class. + /// </summary> + /// <param name="input"></param> + /// <returns></returns> + public abstract bool IsMatch (string input); + + private readonly string _name; + + /// <summary> + /// Creates a new instance of the class. + /// </summary> + protected AnsiKeyboardParserPattern () { _name = GetType ().Name; } + + /// <summary> + /// Returns the <see cref="Key"/> described by the escape sequence. + /// </summary> + /// <param name="input"></param> + /// <returns></returns> + public Key? GetKey (string input) + { + Key? key = GetKeyImpl (input); + Logging.Trace ($"{nameof (AnsiKeyboardParser)} interpreted {input} as {key} using {_name}"); + + return key; + } + + /// <summary> + /// When overriden in a derived class, returns the <see cref="Key"/> + /// that matches the input ansi escape sequence. + /// </summary> + /// <param name="input"></param> + /// <returns></returns> + protected abstract Key? GetKeyImpl (string input); +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs new file mode 100644 index 0000000000..9c442e5e21 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/CsiKeyPattern.cs @@ -0,0 +1,86 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// <summary> +/// Detects ansi escape sequences in strings that have been read from +/// the terminal (see <see cref="IAnsiResponseParser"/>). This pattern +/// handles keys that begin <c>Esc[</c> e.g. <c>Esc[A</c> - cursor up +/// </summary> +public class CsiKeyPattern : AnsiKeyboardParserPattern +{ + private readonly Dictionary<string, Key> _terminators = new() + { + { "A", Key.CursorUp }, + { "B", Key.CursorDown }, + { "C", Key.CursorRight }, + { "D", Key.CursorLeft }, + { "H", Key.Home }, // Home (older variant) + { "F", Key.End }, // End (older variant) + { "1~", Key.Home }, // Home (modern variant) + { "4~", Key.End }, // End (modern variant) + { "5~", Key.PageUp }, + { "6~", Key.PageDown }, + { "2~", Key.InsertChar }, + { "3~", Key.Delete }, + { "11~", Key.F1 }, + { "12~", Key.F2 }, + { "13~", Key.F3 }, + { "14~", Key.F4 }, + { "15~", Key.F5 }, + { "17~", Key.F6 }, + { "18~", Key.F7 }, + { "19~", Key.F8 }, + { "20~", Key.F9 }, + { "21~", Key.F10 }, + { "23~", Key.F11 }, + { "24~", Key.F12 } + }; + + private readonly Regex _pattern; + + /// <inheritdoc/> + public override bool IsMatch (string input) { return _pattern.IsMatch (input); } + + /// <summary> + /// Creates a new instance of the <see cref="CsiKeyPattern"/> class. + /// </summary> + public CsiKeyPattern () + { + var terms = new string (_terminators.Select (k => k.Key [0]).Where (k => !char.IsDigit (k)).ToArray ()); + _pattern = new (@$"^\u001b\[(1;(\d+))?([{terms}]|\d+~)$"); + } + + protected override Key? GetKeyImpl (string input) + { + Match match = _pattern.Match (input); + + if (!match.Success) + { + return null; + } + + string terminator = match.Groups [3].Value; + string modifierGroup = match.Groups [2].Value; + + Key? key = _terminators.GetValueOrDefault (terminator); + + if (key != null && int.TryParse (modifierGroup, out int modifier)) + { + key = modifier switch + { + 2 => key.WithShift, + 3 => key.WithAlt, + 4 => key.WithAlt.WithShift, + 5 => key.WithCtrl, + 6 => key.WithCtrl.WithShift, + 7 => key.WithCtrl.WithAlt, + 8 => key.WithCtrl.WithAlt.WithShift, + _ => key + }; + } + + return key; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/EscAsAltPattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/EscAsAltPattern.cs new file mode 100644 index 0000000000..05fc5ebe48 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/EscAsAltPattern.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +internal class EscAsAltPattern : AnsiKeyboardParserPattern +{ + public EscAsAltPattern () { IsLastMinute = true; } + + private static readonly Regex _pattern = new (@"^\u001b([a-zA-Z0-9_])$"); + + public override bool IsMatch (string input) { return _pattern.IsMatch (input); } + + protected override Key? GetKeyImpl (string input) + { + Match match = _pattern.Match (input); + + if (!match.Success) + { + return null; + } + + char key = match.Groups [1].Value [0]; + + return new Key (key).WithAlt; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/Ss3Pattern.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/Ss3Pattern.cs new file mode 100644 index 0000000000..2e5847e0d1 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/Keyboard/Ss3Pattern.cs @@ -0,0 +1,45 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// <summary> +/// Parser for SS3 terminal escape sequences. These describe specific keys e.g. +/// <c>EscOP</c> is F1. +/// </summary> +public class Ss3Pattern : AnsiKeyboardParserPattern +{ + private static readonly Regex _pattern = new (@"^\u001bO([PQRStDCAB])$"); + + /// <inheritdoc/> + public override bool IsMatch (string input) { return _pattern.IsMatch (input); } + + /// <summary> + /// Returns the ss3 key that corresponds to the provided input escape sequence + /// </summary> + /// <param name="input"></param> + /// <returns></returns> + protected override Key? GetKeyImpl (string input) + { + Match match = _pattern.Match (input); + + if (!match.Success) + { + return null; + } + + return match.Groups [1].Value.Single () switch + { + 'P' => Key.F1, + 'Q' => Key.F2, + 'R' => Key.F3, + 'S' => Key.F4, + 't' => Key.F5, + 'D' => Key.CursorLeft, + 'C' => Key.CursorRight, + 'A' => Key.CursorUp, + 'B' => Key.CursorDown, + _ => null + }; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs index f8b7f4e0a0..376781e702 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/StringHeld.cs @@ -15,4 +15,7 @@ internal class StringHeld : IHeld public IEnumerable<object> HeldToObjects () { return _held.ToString ().Select (c => (object)c); } public void AddToHeld (object o) { _held.Append ((char)o); } + + /// <inheritdoc/> + public int Length => _held.Length; } diff --git a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs index 2ba8ba6018..46b7f50332 100644 --- a/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/ConsoleDriver.cs @@ -4,7 +4,7 @@ namespace Terminal.Gui; -/// <summary>Base class for Terminal.Gui ConsoleDriver implementations.</summary> +/// <summary>Base class for Terminal.Gui IConsoleDriver implementations.</summary> /// <remarks> /// There are currently four implementations: - <see cref="CursesDriver"/> (for Unix and Mac) - /// <see cref="WindowsDriver"/> - <see cref="NetDriver"/> that uses the .NET Console API - <see cref="FakeConsole"/> @@ -558,19 +558,19 @@ public void Refresh () #region Color Handling - /// <summary>Gets whether the <see cref="ConsoleDriver"/> supports TrueColor output.</summary> + /// <summary>Gets whether the <see cref="IConsoleDriver"/> supports TrueColor output.</summary> public virtual bool SupportsTrueColor => true; - // TODO: This makes ConsoleDriver dependent on Application, which is not ideal. This should be moved to Application. + // TODO: This makes IConsoleDriver dependent on Application, which is not ideal. This should be moved to Application. // BUGBUG: Application.Force16Colors should be bool? so if SupportsTrueColor and Application.Force16Colors == false, this doesn't override /// <summary> - /// Gets or sets whether the <see cref="ConsoleDriver"/> should use 16 colors instead of the default TrueColors. + /// Gets or sets whether the <see cref="IConsoleDriver"/> should use 16 colors instead of the default TrueColors. /// See <see cref="Application.Force16Colors"/> to change this setting via <see cref="ConfigurationManager"/>. /// </summary> /// <remarks> /// <para> - /// Will be forced to <see langword="true"/> if <see cref="ConsoleDriver.SupportsTrueColor"/> is - /// <see langword="false"/>, indicating that the <see cref="ConsoleDriver"/> cannot support TrueColor. + /// Will be forced to <see langword="true"/> if <see cref="IConsoleDriver.SupportsTrueColor"/> is + /// <see langword="false"/>, indicating that the <see cref="IConsoleDriver"/> cannot support TrueColor. /// </para> /// </remarks> public virtual bool Force16Colors @@ -592,7 +592,7 @@ public Attribute CurrentAttribute get => _currentAttribute; set { - // TODO: This makes ConsoleDriver dependent on Application, which is not ideal. Once Attribute.PlatformColor is removed, this can be fixed. + // TODO: This makes IConsoleDriver dependent on Application, which is not ideal. Once Attribute.PlatformColor is removed, this can be fixed. if (Application.Driver is { }) { _currentAttribute = new (value.Foreground, value.Background); diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/UnixMainLoop.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/UnixMainLoop.cs index 4b2daea409..cb6a0f5d12 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/UnixMainLoop.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/UnixMainLoop.cs @@ -102,7 +102,7 @@ bool IMainLoopDriver.EventsPending () UpdatePollMap (); - bool checkTimersResult = _mainLoop!.CheckTimersAndIdleHandlers (out int pollTimeout); + bool checkTimersResult = _mainLoop!.TimedEvents.CheckTimersAndIdleHandlers (out int pollTimeout); int n = poll (_pollMap!, (uint)_pollMap!.Length, pollTimeout); diff --git a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs index 70c4ba4478..5e9a883d72 100644 --- a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs @@ -1,5 +1,5 @@ // -// FakeDriver.cs: A fake ConsoleDriver for unit tests. +// FakeDriver.cs: A fake IConsoleDriver for unit tests. // using System.Diagnostics; @@ -10,7 +10,7 @@ namespace Terminal.Gui; -/// <summary>Implements a mock ConsoleDriver for unit testing</summary> +/// <summary>Implements a mock IConsoleDriver for unit testing</summary> public class FakeDriver : ConsoleDriver { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member diff --git a/Terminal.Gui/ConsoleDrivers/IConsoleDriver.cs b/Terminal.Gui/ConsoleDrivers/IConsoleDriver.cs index af41abe667..a661b84393 100644 --- a/Terminal.Gui/ConsoleDrivers/IConsoleDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/IConsoleDriver.cs @@ -22,6 +22,7 @@ public interface IConsoleDriver /// <value>The rectangle describing the of <see cref="Clip"/> region.</value> Region? Clip { get; set; } + /// <summary> /// Gets the column last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. @@ -33,8 +34,7 @@ public interface IConsoleDriver // BUGBUG: This should not be publicly settable. /// <summary> - /// Gets or sets the contents of the application output. The driver outputs this buffer to the terminal when - /// <see cref="UpdateScreen"/> is called. + /// Gets or sets the contents of the application output. The driver outputs this buffer to the terminal. /// <remarks>The format of the array is rows, columns. The first index is the row, the second index is the column.</remarks> /// </summary> Cell [,]? Contents { get; set; } @@ -93,17 +93,6 @@ public interface IConsoleDriver /// </returns> bool IsRuneSupported (Rune rune); - // BUGBUG: This is not referenced. Can it be removed? - /// <summary>Tests whether the specified coordinate are valid for drawing.</summary> - /// <param name="col">The column.</param> - /// <param name="row">The row.</param> - /// <returns> - /// <see langword="false"/> if the coordinate is outside the screen bounds or outside of - /// <see cref="ConsoleDriver.Clip"/>. - /// <see langword="true"/> otherwise. - /// </returns> - bool IsValidLocation (int col, int row); - /// <summary>Tests whether the specified coordinate are valid for drawing the specified Rune.</summary> /// <param name="rune">Used to determine if one or two columns are required.</param> /// <param name="col">The column.</param> @@ -173,9 +162,15 @@ public interface IConsoleDriver /// <param name="str">String.</param> void AddStr (string str); + /// <summary>Clears the <see cref="ConsoleDriver.Contents"/> of the driver.</summary> + void ClearContents (); + /// <summary> /// Fills the specified rectangle with the specified rune, using <see cref="ConsoleDriver.CurrentAttribute"/> /// </summary> + event EventHandler<EventArgs> ClearedContents; + + /// <summary>Fills the specified rectangle with the specified rune, using <see cref="ConsoleDriver.CurrentAttribute"/></summary> /// <remarks> /// The value of <see cref="ConsoleDriver.Clip"/> is honored. Any parts of the rectangle not in the clip will not be /// drawn. @@ -192,31 +187,15 @@ public interface IConsoleDriver /// <param name="c"></param> void FillRect (Rectangle rect, char c); - /// <summary>Clears the <see cref="ConsoleDriver.Contents"/> of the driver.</summary> - void ClearContents (); - - /// <summary> - /// Raised each time <see cref="ConsoleDriver.ClearContents"/> is called. For benchmarking. - /// </summary> - event EventHandler<EventArgs>? ClearedContents; /// <summary>Gets the terminal cursor visibility.</summary> /// <param name="visibility">The current <see cref="CursorVisibility"/></param> /// <returns><see langword="true"/> upon success</returns> bool GetCursorVisibility (out CursorVisibility visibility); - /// <summary>Called when the terminal size changes. Fires the <see cref="ConsoleDriver.SizeChanged"/> event.</summary> - /// <param name="args"></param> - void OnSizeChanged (SizeChangedEventArgs args); - /// <summary>Updates the screen to reflect all the changes that have been done to the display buffer</summary> void Refresh (); - /// <summary> - /// Raised each time <see cref="ConsoleDriver.Refresh"/> is called. For benchmarking. - /// </summary> - event EventHandler<EventArgs<bool>>? Refreshed; - /// <summary>Sets the terminal cursor visibility.</summary> /// <param name="visibility">The wished <see cref="CursorVisibility"/></param> /// <returns><see langword="true"/> upon success</returns> @@ -235,10 +214,6 @@ public interface IConsoleDriver /// </summary> void UpdateCursor (); - /// <summary>Redraws the physical screen with the contents that have been queued up via any of the printing commands.</summary> - /// <returns><see langword="true"/> if any updates to the screen were made.</returns> - bool UpdateScreen (); - /// <summary>Initializes the driver</summary> /// <returns>Returns an instance of <see cref="MainLoop"/> using the <see cref="IMainLoopDriver"/> for the driver.</returns> MainLoop Init (); @@ -264,21 +239,9 @@ public interface IConsoleDriver /// <summary>Event fired when a mouse event occurs.</summary> event EventHandler<MouseEventArgs>? MouseEvent; - /// <summary>Called when a mouse event occurs. Fires the <see cref="ConsoleDriver.MouseEvent"/> event.</summary> - /// <param name="a"></param> - void OnMouseEvent (MouseEventArgs a); - /// <summary>Event fired when a key is pressed down. This is a precursor to <see cref="ConsoleDriver.KeyUp"/>.</summary> event EventHandler<Key>? KeyDown; - // BUGBUG: This is not referenced. Can it be removed? - /// <summary> - /// Called when a key is pressed down. Fires the <see cref="ConsoleDriver.KeyDown"/> event. This is a precursor to - /// <see cref="ConsoleDriver.OnKeyUp"/>. - /// </summary> - /// <param name="a"></param> - void OnKeyDown (Key a); - /// <summary>Event fired when a key is released.</summary> /// <remarks> /// Drivers that do not support key release events will fire this event after <see cref="ConsoleDriver.KeyDown"/> @@ -287,16 +250,6 @@ public interface IConsoleDriver /// </remarks> event EventHandler<Key>? KeyUp; - // BUGBUG: This is not referenced. Can it be removed? - /// <summary>Called when a key is released. Fires the <see cref="ConsoleDriver.KeyUp"/> event.</summary> - /// <remarks> - /// Drivers that do not support key release events will call this method after <see cref="ConsoleDriver.OnKeyDown"/> - /// processing - /// is complete. - /// </remarks> - /// <param name="a"></param> - void OnKeyUp (Key a); - /// <summary>Simulates a key press.</summary> /// <param name="keyChar">The key character.</param> /// <param name="key">The key.</param> @@ -305,11 +258,6 @@ public interface IConsoleDriver /// <param name="ctrl">If <see langword="true"/> simulates the Ctrl key being pressed.</param> void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl); - /// <summary> - /// How long after Esc has been pressed before we give up on getting an Ansi escape sequence - /// </summary> - public TimeSpan EscTimeout { get; } - /// <summary> /// Queues the given <paramref name="request"/> for execution /// </summary> diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs b/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs index 391eb51961..a83b84921c 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver/NetEvents.cs @@ -96,8 +96,8 @@ private ConsoleKeyInfo ReadConsoleKeyInfo (bool intercept = true) public IEnumerable<ConsoleKeyInfo> ShouldReleaseParserHeldKeys () { - if (Parser.State == AnsiResponseParserState.ExpectingBracket && - DateTime.Now - Parser.StateChangedAt > _consoleDriver.EscTimeout) + if (Parser.State == AnsiResponseParserState.ExpectingEscapeSequence && + DateTime.Now - Parser.StateChangedAt > ((NetDriver)_consoleDriver).EscTimeout) { return Parser.Release ().Select (o => o.Item2); } diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver/NetMainLoop.cs b/Terminal.Gui/ConsoleDrivers/NetDriver/NetMainLoop.cs index 37be0766f6..cf5ee0e767 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver/NetMainLoop.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver/NetMainLoop.cs @@ -55,7 +55,7 @@ bool IMainLoopDriver.EventsPending () _waitForProbe.Set (); - if (_resultQueue.Count > 0 || _mainLoop!.CheckTimersAndIdleHandlers (out int waitTimeout)) + if (_resultQueue.Count > 0 || _mainLoop!.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeout)) { return true; } @@ -82,7 +82,7 @@ bool IMainLoopDriver.EventsPending () if (!_eventReadyTokenSource.IsCancellationRequested) { - return _resultQueue.Count > 0 || _mainLoop.CheckTimersAndIdleHandlers (out _); + return _resultQueue.Count > 0 || _mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out _); } // If cancellation was requested then always return true diff --git a/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs b/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs new file mode 100644 index 0000000000..5679f19136 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs @@ -0,0 +1,235 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// <summary> +/// Implementation of <see cref="IApplication"/> that boots the new 'v2' +/// main loop architecture. +/// </summary> +public class ApplicationV2 : ApplicationImpl +{ + private readonly Func<INetInput> _netInputFactory; + private readonly Func<IConsoleOutput> _netOutputFactory; + private readonly Func<IWindowsInput> _winInputFactory; + private readonly Func<IConsoleOutput> _winOutputFactory; + private IMainLoopCoordinator? _coordinator; + private string? _driverName; + + private readonly ITimedEvents _timedEvents = new TimedEvents (); + + /// <summary> + /// Creates anew instance of the Application backend. The provided + /// factory methods will be used on Init calls to get things booted. + /// </summary> + public ApplicationV2 () : this ( + () => new NetInput (), + () => new NetOutput (), + () => new WindowsInput (), + () => new WindowsOutput () + ) + { } + + internal ApplicationV2 ( + Func<INetInput> netInputFactory, + Func<IConsoleOutput> netOutputFactory, + Func<IWindowsInput> winInputFactory, + Func<IConsoleOutput> winOutputFactory + ) + { + _netInputFactory = netInputFactory; + _netOutputFactory = netOutputFactory; + _winInputFactory = winInputFactory; + _winOutputFactory = winOutputFactory; + IsLegacy = false; + } + + /// <inheritdoc/> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public override void Init (IConsoleDriver? driver = null, string? driverName = null) + { + if (Application.Initialized) + { + Logging.Logger.LogError ("Init called multiple times without shutdown, ignoring."); + + return; + } + + if (!string.IsNullOrWhiteSpace (driverName)) + { + _driverName = driverName; + } + + Application.Navigation = new (); + + Application.AddKeyBindings (); + + // This is consistent with Application.ForceDriver which magnetically picks up driverName + // making it use custom driver in future shutdown/init calls where no driver is specified + CreateDriver (driverName ?? _driverName); + + Application.InitializeConfigurationManagement (); + + Application.Initialized = true; + + Application.OnInitializedChanged (this, new (true)); + Application.SubscribeDriverEvents (); + } + + private void CreateDriver (string? driverName) + { + PlatformID p = Environment.OSVersion.Platform; + + bool definetlyWin = driverName?.Contains ("win") ?? false; + bool definetlyNet = driverName?.Contains ("net") ?? false; + + if (definetlyWin) + { + _coordinator = CreateWindowsSubcomponents (); + } + else if (definetlyNet) + { + _coordinator = CreateNetSubcomponents (); + } + else if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + { + _coordinator = CreateWindowsSubcomponents (); + } + else + { + _coordinator = CreateNetSubcomponents (); + } + + _coordinator.StartAsync ().Wait (); + + if (Application.Driver == null) + { + throw new ("Application.Driver was null even after booting MainLoopCoordinator"); + } + } + + private IMainLoopCoordinator CreateWindowsSubcomponents () + { + ConcurrentQueue<WindowsConsole.InputRecord> inputBuffer = new (); + MainLoop<WindowsConsole.InputRecord> loop = new (); + + return new MainLoopCoordinator<WindowsConsole.InputRecord> ( + _timedEvents, + _winInputFactory, + inputBuffer, + new WindowsInputProcessor (inputBuffer), + _winOutputFactory, + loop); + } + + private IMainLoopCoordinator CreateNetSubcomponents () + { + ConcurrentQueue<ConsoleKeyInfo> inputBuffer = new (); + MainLoop<ConsoleKeyInfo> loop = new (); + + return new MainLoopCoordinator<ConsoleKeyInfo> ( + _timedEvents, + _netInputFactory, + inputBuffer, + new NetInputProcessor (inputBuffer), + _netOutputFactory, + loop); + } + + /// <inheritdoc/> + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public override T Run<T> (Func<Exception, bool>? errorHandler = null, IConsoleDriver? driver = null) + { + var top = new T (); + + Run (top, errorHandler); + + return top; + } + + /// <inheritdoc/> + public override void Run (Toplevel view, Func<Exception, bool>? errorHandler = null) + { + Logging.Logger.LogInformation ($"Run '{view}'"); + ArgumentNullException.ThrowIfNull (view); + + if (!Application.Initialized) + { + throw new NotInitializedException (nameof (Run)); + } + + Application.Top = view; + + Application.Begin (view); + + // TODO : how to know when we are done? + while (Application.TopLevels.TryPeek (out Toplevel? found) && found == view) + { + if (_coordinator is null) + { + throw new ($"{nameof (IMainLoopCoordinator)}inexplicably became null during Run"); + } + + _coordinator.RunIteration (); + } + } + + /// <inheritdoc/> + public override void Shutdown () + { + _coordinator?.Stop (); + base.Shutdown (); + Application.Driver = null; + } + + /// <inheritdoc/> + public override void RequestStop (Toplevel? top) + { + Logging.Logger.LogInformation ($"RequestStop '{top}'"); + + // 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; + } + } + + /// <inheritdoc/> + public override void Invoke (Action action) + { + _timedEvents.AddIdle ( + () => + { + action (); + + return false; + } + ); + } + + /// <inheritdoc/> + public override void AddIdle (Func<bool> func) { _timedEvents.AddIdle (func); } + + /// <summary> + /// Removes an idle function added by <see cref="AddIdle"/> + /// </summary> + /// <param name="fnTrue">Function to remove</param> + /// <returns>True if it was found and removed</returns> + public bool RemoveIdle (Func<bool> fnTrue) { return _timedEvents.RemoveIdle (fnTrue); } + + /// <inheritdoc/> + public override object AddTimeout (TimeSpan time, Func<bool> callback) { return _timedEvents.AddTimeout (time, callback); } + + /// <inheritdoc/> + public override bool RemoveTimeout (object token) { return _timedEvents.RemoveTimeout (token); } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/ConsoleDriverFacade.cs b/Terminal.Gui/ConsoleDrivers/V2/ConsoleDriverFacade.cs new file mode 100644 index 0000000000..aae6c855d8 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ConsoleDriverFacade.cs @@ -0,0 +1,388 @@ +using System.Runtime.InteropServices; + +namespace Terminal.Gui; + +internal class ConsoleDriverFacade<T> : IConsoleDriver, IConsoleDriverFacade +{ + private readonly IConsoleOutput _output; + private readonly IOutputBuffer _outputBuffer; + private readonly AnsiRequestScheduler _ansiRequestScheduler; + private CursorVisibility _lastCursor = CursorVisibility.Default; + + /// <summary>The event fired when the terminal is resized.</summary> + public event EventHandler<SizeChangedEventArgs> SizeChanged; + + public IInputProcessor InputProcessor { get; } + + public ConsoleDriverFacade ( + IInputProcessor inputProcessor, + IOutputBuffer outputBuffer, + IConsoleOutput output, + AnsiRequestScheduler ansiRequestScheduler, + IWindowSizeMonitor windowSizeMonitor + ) + { + InputProcessor = inputProcessor; + _output = output; + _outputBuffer = outputBuffer; + _ansiRequestScheduler = ansiRequestScheduler; + + InputProcessor.KeyDown += (s, e) => KeyDown?.Invoke (s, e); + InputProcessor.KeyUp += (s, e) => KeyUp?.Invoke (s, e); + InputProcessor.MouseEvent += (s, e) => MouseEvent?.Invoke (s, e); + + windowSizeMonitor.SizeChanging += (_, e) => SizeChanged?.Invoke (this, e); + + CreateClipboard (); + } + + private void CreateClipboard () + { + PlatformID p = Environment.OSVersion.Platform; + + if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + { + Clipboard = new WindowsClipboard (); + } + else if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX)) + { + Clipboard = new MacOSXClipboard (); + } + else if (CursesDriver.Is_WSL_Platform ()) + { + Clipboard = new WSLClipboard (); + } + else + { + Clipboard = new FakeDriver.FakeClipboard (); + } + } + + /// <summary>Gets the location and size of the terminal screen.</summary> + public Rectangle Screen => new (new (0, 0), _output.GetWindowSize ()); + + /// <summary> + /// Gets or sets the clip rectangle that <see cref="AddRune(Rune)"/> and <see cref="AddStr(string)"/> are subject + /// to. + /// </summary> + /// <value>The rectangle describing the of <see cref="Clip"/> region.</value> + public Region Clip + { + get => _outputBuffer.Clip; + set => _outputBuffer.Clip = value; + } + + /// <summary>Get the operating system clipboard.</summary> + public IClipboard Clipboard { get; private set; } = new FakeDriver.FakeClipboard (); + + /// <summary> + /// Gets the column last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Col => _outputBuffer.Col; + + /// <summary>The number of columns visible in the terminal.</summary> + public int Cols + { + get => _outputBuffer.Cols; + set => _outputBuffer.Cols = value; + } + + /// <summary> + /// The contents of the application output. The driver outputs this buffer to the terminal. + /// <remarks>The format of the array is rows, columns. The first index is the row, the second index is the column.</remarks> + /// </summary> + public Cell [,] Contents + { + get => _outputBuffer.Contents; + set => _outputBuffer.Contents = value; + } + + /// <summary>The leftmost column in the terminal.</summary> + public int Left + { + get => _outputBuffer.Left; + set => _outputBuffer.Left = value; + } + + /// <summary> + /// Gets the row last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Row => _outputBuffer.Row; + + /// <summary>The number of rows visible in the terminal.</summary> + public int Rows + { + get => _outputBuffer.Rows; + set => _outputBuffer.Rows = value; + } + + /// <summary>The topmost row in the terminal.</summary> + public int Top + { + get => _outputBuffer.Top; + set => _outputBuffer.Top = value; + } + + // TODO: Probably not everyone right? + + /// <summary>Gets whether the <see cref="ConsoleDriver"/> supports TrueColor output.</summary> + public bool SupportsTrueColor => true; + + // TODO: Currently ignored + /// <summary> + /// Gets or sets whether the <see cref="ConsoleDriver"/> should use 16 colors instead of the default TrueColors. + /// See <see cref="Application.Force16Colors"/> to change this setting via <see cref="ConfigurationManager"/>. + /// </summary> + /// <remarks> + /// <para> + /// Will be forced to <see langword="true"/> if <see cref="ConsoleDriver.SupportsTrueColor"/> is + /// <see langword="false"/>, indicating that the <see cref="ConsoleDriver"/> cannot support TrueColor. + /// </para> + /// </remarks> + public bool Force16Colors { get; set; } + + /// <summary> + /// The <see cref="Attribute"/> that will be used for the next <see cref="AddRune(Rune)"/> or <see cref="AddStr"/> + /// call. + /// </summary> + public Attribute CurrentAttribute + { + get => _outputBuffer.CurrentAttribute; + set => _outputBuffer.CurrentAttribute = value; + } + + /// <summary>Adds the specified rune to the display at the current cursor position.</summary> + /// <remarks> + /// <para> + /// When the method returns, <see cref="ConsoleDriver.Col"/> will be incremented by the number of columns + /// <paramref name="rune"/> required, even if the new column value is outside of the + /// <see cref="ConsoleDriver.Clip"/> or screen + /// dimensions defined by <see cref="ConsoleDriver.Cols"/>. + /// </para> + /// <para> + /// If <paramref name="rune"/> requires more than one column, and <see cref="ConsoleDriver.Col"/> plus the number + /// of columns + /// needed exceeds the <see cref="ConsoleDriver.Clip"/> or screen dimensions, the default Unicode replacement + /// character (U+FFFD) + /// will be added instead. + /// </para> + /// </remarks> + /// <param name="rune">Rune to add.</param> + public void AddRune (Rune rune) { _outputBuffer.AddRune (rune); } + + /// <summary> + /// Adds the specified <see langword="char"/> to the display at the current cursor position. This method is a + /// convenience method that calls <see cref="ConsoleDriver.AddRune(System.Text.Rune)"/> with the <see cref="Rune"/> + /// constructor. + /// </summary> + /// <param name="c">Character to add.</param> + public void AddRune (char c) { _outputBuffer.AddRune (c); } + + /// <summary>Adds the <paramref name="str"/> to the display at the cursor position.</summary> + /// <remarks> + /// <para> + /// When the method returns, <see cref="ConsoleDriver.Col"/> will be incremented by the number of columns + /// <paramref name="str"/> required, unless the new column value is outside of the <see cref="ConsoleDriver.Clip"/> + /// or screen + /// dimensions defined by <see cref="ConsoleDriver.Cols"/>. + /// </para> + /// <para>If <paramref name="str"/> requires more columns than are available, the output will be clipped.</para> + /// </remarks> + /// <param name="str">String.</param> + public void AddStr (string str) { _outputBuffer.AddStr (str); } + + /// <summary>Clears the <see cref="ConsoleDriver.Contents"/> of the driver.</summary> + public void ClearContents () + { + _outputBuffer.ClearContents (); + ClearedContents?.Invoke (this, new MouseEventArgs ()); + } + + /// <summary> + /// Raised each time <see cref="ConsoleDriver.ClearContents"/> is called. For benchmarking. + /// </summary> + public event EventHandler<EventArgs> ClearedContents; + + /// <summary> + /// Fills the specified rectangle with the specified rune, using <see cref="ConsoleDriver.CurrentAttribute"/> + /// </summary> + /// <remarks> + /// The value of <see cref="ConsoleDriver.Clip"/> is honored. Any parts of the rectangle not in the clip will not be + /// drawn. + /// </remarks> + /// <param name="rect">The Screen-relative rectangle.</param> + /// <param name="rune">The Rune used to fill the rectangle</param> + public void FillRect (Rectangle rect, Rune rune = default) { _outputBuffer.FillRect (rect, rune); } + + /// <summary> + /// Fills the specified rectangle with the specified <see langword="char"/>. This method is a convenience method + /// that calls <see cref="ConsoleDriver.FillRect(System.Drawing.Rectangle,System.Text.Rune)"/>. + /// </summary> + /// <param name="rect"></param> + /// <param name="c"></param> + public void FillRect (Rectangle rect, char c) { _outputBuffer.FillRect (rect, c); } + + /// <inheritdoc/> + public virtual string GetVersionInfo () + { + var type = ""; + + if (InputProcessor is WindowsInputProcessor) + { + type = "(win)"; + } + else if (InputProcessor is NetInputProcessor) + { + type = "(net)"; + } + + return GetType ().Name.TrimEnd ('`', '1') + type; + } + + /// <summary>Tests if the specified rune is supported by the driver.</summary> + /// <param name="rune"></param> + /// <returns> + /// <see langword="true"/> if the rune can be properly presented; <see langword="false"/> if the driver does not + /// support displaying this rune. + /// </returns> + public bool IsRuneSupported (Rune rune) { return Rune.IsValid (rune.Value); } + + /// <summary>Tests whether the specified coordinate are valid for drawing the specified Rune.</summary> + /// <param name="rune">Used to determine if one or two columns are required.</param> + /// <param name="col">The column.</param> + /// <param name="row">The row.</param> + /// <returns> + /// <see langword="false"/> if the coordinate is outside the screen bounds or outside of + /// <see cref="ConsoleDriver.Clip"/>. + /// <see langword="true"/> otherwise. + /// </returns> + public bool IsValidLocation (Rune rune, int col, int row) { return _outputBuffer.IsValidLocation (rune, col, row); } + + /// <summary> + /// Updates <see cref="ConsoleDriver.Col"/> and <see cref="ConsoleDriver.Row"/> to the specified column and row in + /// <see cref="ConsoleDriver.Contents"/>. + /// Used by <see cref="ConsoleDriver.AddRune(System.Text.Rune)"/> and <see cref="ConsoleDriver.AddStr"/> to determine + /// where to add content. + /// </summary> + /// <remarks> + /// <para>This does not move the cursor on the screen, it only updates the internal state of the driver.</para> + /// <para> + /// If <paramref name="col"/> or <paramref name="row"/> are negative or beyond <see cref="ConsoleDriver.Cols"/> + /// and + /// <see cref="ConsoleDriver.Rows"/>, the method still sets those properties. + /// </para> + /// </remarks> + /// <param name="col">Column to move to.</param> + /// <param name="row">Row to move to.</param> + public void Move (int col, int row) { _outputBuffer.Move (col, row); } + + // TODO: Probably part of output + + /// <summary>Sets the terminal cursor visibility.</summary> + /// <param name="visibility">The wished <see cref="CursorVisibility"/></param> + /// <returns><see langword="true"/> upon success</returns> + public bool SetCursorVisibility (CursorVisibility visibility) + { + _lastCursor = visibility; + _output.SetCursorVisibility (visibility); + + return true; + } + + /// <inheritdoc/> + public bool GetCursorVisibility (out CursorVisibility current) + { + current = _lastCursor; + + return true; + } + + /// <inheritdoc/> + public void Suspend () { } + + /// <summary> + /// Sets the position of the terminal cursor to <see cref="ConsoleDriver.Col"/> and + /// <see cref="ConsoleDriver.Row"/>. + /// </summary> + public void UpdateCursor () { _output.SetCursorPosition (Col, Row); } + + /// <summary>Initializes the driver</summary> + /// <returns>Returns an instance of <see cref="MainLoop"/> using the <see cref="IMainLoopDriver"/> for the driver.</returns> + public MainLoop Init () { throw new NotSupportedException (); } + + /// <summary>Ends the execution of the console driver.</summary> + public void End () + { + // TODO: Nope + } + + /// <summary>Selects the specified attribute as the attribute to use for future calls to AddRune and AddString.</summary> + /// <remarks>Implementations should call <c>base.SetAttribute(c)</c>.</remarks> + /// <param name="c">C.</param> + public Attribute SetAttribute (Attribute c) { return _outputBuffer.CurrentAttribute = c; } + + /// <summary>Gets the current <see cref="Attribute"/>.</summary> + /// <returns>The current attribute.</returns> + public Attribute GetAttribute () { return _outputBuffer.CurrentAttribute; } + + /// <summary>Makes an <see cref="Attribute"/>.</summary> + /// <param name="foreground">The foreground color.</param> + /// <param name="background">The background color.</param> + /// <returns>The attribute for the foreground and background colors.</returns> + public Attribute MakeColor (in Color foreground, in Color background) + { + // TODO: what even is this? why Attribute constructor wants to call Driver method which must return an instance of Attribute? ?!?!?! + return new ( + -1, // only used by cursesdriver! + foreground, + background + ); + } + + /// <summary>Event fired when a key is pressed down. This is a precursor to <see cref="ConsoleDriver.KeyUp"/>.</summary> + public event EventHandler<Key> KeyDown; + + /// <summary>Event fired when a key is released.</summary> + /// <remarks> + /// Drivers that do not support key release events will fire this event after <see cref="ConsoleDriver.KeyDown"/> + /// processing is + /// complete. + /// </remarks> + public event EventHandler<Key> KeyUp; + + /// <summary>Event fired when a mouse event occurs.</summary> + public event EventHandler<MouseEventArgs> MouseEvent; + + /// <summary>Simulates a key press.</summary> + /// <param name="keyChar">The key character.</param> + /// <param name="key">The key.</param> + /// <param name="shift">If <see langword="true"/> simulates the Shift key being pressed.</param> + /// <param name="alt">If <see langword="true"/> simulates the Alt key being pressed.</param> + /// <param name="ctrl">If <see langword="true"/> simulates the Ctrl key being pressed.</param> + public void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl) + { + // TODO: implement + } + + /// <summary> + /// Provide proper writing to send escape sequence recognized by the <see cref="ConsoleDriver"/>. + /// </summary> + /// <param name="ansi"></param> + public void WriteRaw (string ansi) { _output.Write (ansi); } + + /// <summary> + /// Queues the given <paramref name="request"/> for execution + /// </summary> + /// <param name="request"></param> + public void QueueAnsiRequest (AnsiEscapeSequenceRequest request) { _ansiRequestScheduler.SendOrSchedule (request); } + + public AnsiRequestScheduler GetRequestScheduler () { return _ansiRequestScheduler; } + + /// <inheritdoc/> + public void Refresh () + { + // No need we will always draw when dirty + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/ConsoleInput.cs b/Terminal.Gui/ConsoleDrivers/V2/ConsoleInput.cs new file mode 100644 index 0000000000..d3ddbbd02d --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ConsoleInput.cs @@ -0,0 +1,79 @@ +#nullable enable +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// <summary> +/// Base class for reading console input in perpetual loop +/// </summary> +/// <typeparam name="T"></typeparam> +public abstract class ConsoleInput<T> : IConsoleInput<T> +{ + private ConcurrentQueue<T>? _inputBuffer; + + /// <summary> + /// Determines how to get the current system type, adjust + /// in unit tests to simulate specific timings. + /// </summary> + public Func<DateTime> Now { get; set; } = () => DateTime.Now; + + /// <inheritdoc/> + public virtual void Dispose () { } + + /// <inheritdoc/> + public void Initialize (ConcurrentQueue<T> inputBuffer) { _inputBuffer = inputBuffer; } + + /// <inheritdoc/> + public void Run (CancellationToken token) + { + try + { + if (_inputBuffer == null) + { + throw new ("Cannot run input before Initialization"); + } + + do + { + DateTime dt = Now (); + + while (Peek ()) + { + foreach (T r in Read ()) + { + _inputBuffer.Enqueue (r); + } + } + + TimeSpan took = Now () - dt; + TimeSpan sleepFor = TimeSpan.FromMilliseconds (20) - took; + + Logging.DrainInputStream.Record (took.Milliseconds); + + if (sleepFor.Milliseconds > 0) + { + Task.Delay (sleepFor, token).Wait (token); + } + + token.ThrowIfCancellationRequested (); + } + while (!token.IsCancellationRequested); + } + catch (OperationCanceledException) + { } + } + + /// <summary> + /// When implemented in a derived class, returns true if there is data available + /// to read from console. + /// </summary> + /// <returns></returns> + protected abstract bool Peek (); + + /// <summary> + /// Returns the available data without blocking, called when <see cref="Peek"/> + /// returns <see langword="true"/>. + /// </summary> + /// <returns></returns> + protected abstract IEnumerable<T> Read (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IConsoleDriverFacade.cs b/Terminal.Gui/ConsoleDrivers/V2/IConsoleDriverFacade.cs new file mode 100644 index 0000000000..6d316e6f20 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IConsoleDriverFacade.cs @@ -0,0 +1,14 @@ +namespace Terminal.Gui; + +/// <summary> +/// Interface for v2 driver abstraction layer +/// </summary> +public interface IConsoleDriverFacade +{ + /// <summary> + /// Class responsible for processing native driver input objects + /// e.g. <see cref="ConsoleKeyInfo"/> into <see cref="Key"/> events + /// and detecting and processing ansi escape sequences. + /// </summary> + public IInputProcessor InputProcessor { get; } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs b/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs new file mode 100644 index 0000000000..03f7128614 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// <summary> +/// Interface for reading console input indefinitely - +/// i.e. in an infinite loop. The class is responsible only +/// for reading and storing the input in a thread safe input buffer +/// which is then processed downstream e.g. on main UI thread. +/// </summary> +/// <typeparam name="T"></typeparam> +public interface IConsoleInput<T> : IDisposable +{ + /// <summary> + /// Initializes the input with a buffer into which to put data read + /// </summary> + /// <param name="inputBuffer"></param> + void Initialize (ConcurrentQueue<T> inputBuffer); + + /// <summary> + /// Runs in an infinite input loop. + /// </summary> + /// <param name="token"></param> + /// <exception cref="OperationCanceledException"> + /// Raised when token is + /// cancelled. This is the only means of exiting the input. + /// </exception> + void Run (CancellationToken token); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IConsoleOutput.cs b/Terminal.Gui/ConsoleDrivers/V2/IConsoleOutput.cs new file mode 100644 index 0000000000..6004bf5e1b --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IConsoleOutput.cs @@ -0,0 +1,42 @@ +namespace Terminal.Gui; + +/// <summary> +/// Interface for writing console output +/// </summary> +public interface IConsoleOutput : IDisposable +{ + /// <summary> + /// Writes the given text directly to the console. Use to send + /// ansi escape codes etc. Regular screen output should use the + /// <see cref="IOutputBuffer"/> overload. + /// </summary> + /// <param name="text"></param> + void Write (string text); + + /// <summary> + /// Write the contents of the <paramref name="buffer"/> to the console + /// </summary> + /// <param name="buffer"></param> + void Write (IOutputBuffer buffer); + + /// <summary> + /// Returns the current size of the console window in rows/columns (i.e. + /// of characters not pixels). + /// </summary> + /// <returns></returns> + public Size GetWindowSize (); + + /// <summary> + /// Updates the console cursor (the blinking underscore) to be hidden, + /// visible etc. + /// </summary> + /// <param name="visibility"></param> + void SetCursorVisibility (CursorVisibility visibility); + + /// <summary> + /// Moves the console cursor to the given location. + /// </summary> + /// <param name="col"></param> + /// <param name="row"></param> + void SetCursorPosition (int col, int row); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IInputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/IInputProcessor.cs new file mode 100644 index 0000000000..bb79ec83f4 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IInputProcessor.cs @@ -0,0 +1,60 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Interface for main loop class that will process the queued input buffer contents. +/// Is responsible for <see cref="ProcessQueue"/> and translating into common Terminal.Gui +/// events and data models. +/// </summary> +public interface IInputProcessor +{ + /// <summary>Event fired when a key is pressed down. This is a precursor to <see cref="KeyUp"/>.</summary> + event EventHandler<Key>? KeyDown; + + /// <summary>Event fired when a key is released.</summary> + /// <remarks> + /// Drivers that do not support key release events will fire this event after <see cref="KeyDown"/> processing is + /// complete. + /// </remarks> + event EventHandler<Key>? KeyUp; + + /// <summary>Event fired when a terminal sequence read from input is not recognized and therefore ignored.</summary> + public event EventHandler<string>? AnsiSequenceSwallowed; + + /// <summary>Event fired when a mouse event occurs.</summary> + event EventHandler<MouseEventArgs>? MouseEvent; + + /// <summary> + /// Called when a key is pressed down. Fires the <see cref="KeyDown"/> event. This is a precursor to + /// <see cref="OnKeyUp"/>. + /// </summary> + /// <param name="key">The key event data.</param> + void OnKeyDown (Key key); + + /// <summary> + /// Called when a key is released. Fires the <see cref="KeyUp"/> event. + /// </summary> + /// <remarks> + /// Drivers that do not support key release events will call this method after <see cref="OnKeyDown"/> processing + /// is complete. + /// </remarks> + /// <param name="key">The key event data.</param> + void OnKeyUp (Key key); + + /// <summary> + /// Called when a mouse event occurs. Fires the <see cref="MouseEvent"/> event. + /// </summary> + /// <param name="mouseEventArgs">The mouse event data.</param> + void OnMouseEvent (MouseEventArgs mouseEventArgs); + + /// <summary> + /// Drains the input buffer, processing all available keystrokes + /// </summary> + void ProcessQueue (); + + /// <summary> + /// Gets the response parser currently configured on this input processor. + /// </summary> + /// <returns></returns> + public IAnsiResponseParser GetParser (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IKeyConverter.cs b/Terminal.Gui/ConsoleDrivers/V2/IKeyConverter.cs new file mode 100644 index 0000000000..12cc379fab --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IKeyConverter.cs @@ -0,0 +1,18 @@ +namespace Terminal.Gui; + +/// <summary> +/// Interface for subcomponent of a <see cref="InputProcessor{T}"/> which +/// can translate the raw console input type T (which typically varies by +/// driver) to the shared Terminal.Gui <see cref="Key"/> class. +/// </summary> +/// <typeparam name="T"></typeparam> +public interface IKeyConverter<in T> +{ + /// <summary> + /// Converts the native keyboard class read from console into + /// the shared <see cref="Key"/> class used by Terminal.Gui views. + /// </summary> + /// <param name="value"></param> + /// <returns></returns> + Key ToKey (T value); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IMainLoop.cs b/Terminal.Gui/ConsoleDrivers/V2/IMainLoop.cs new file mode 100644 index 0000000000..73493eb7ea --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IMainLoop.cs @@ -0,0 +1,58 @@ +#nullable enable +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// <summary> +/// Interface for main loop that runs the core Terminal.Gui UI loop. +/// </summary> +/// <typeparam name="T"></typeparam> +public interface IMainLoop<T> : IDisposable +{ + /// <summary> + /// Gets the class responsible for servicing user timeouts and idles + /// </summary> + public ITimedEvents TimedEvents { get; } + + /// <summary> + /// Gets the class responsible for writing final rendered output to the console + /// </summary> + public IOutputBuffer OutputBuffer { get; } + + /// <summary> + /// Class for writing output to the console. + /// </summary> + public IConsoleOutput Out { get; } + + /// <summary> + /// Gets the class responsible for processing buffered console input and translating + /// it into events on the UI thread. + /// </summary> + public IInputProcessor InputProcessor { get; } + + /// <summary> + /// Gets the class responsible for sending ANSI escape requests which expect a response + /// from the remote terminal e.g. Device Attribute Request + /// </summary> + public AnsiRequestScheduler AnsiRequestScheduler { get; } + + /// <summary> + /// Gets the class responsible for determining the current console size + /// </summary> + public IWindowSizeMonitor WindowSizeMonitor { get; } + + /// <summary> + /// Initializes the loop with a buffer from which data can be read + /// </summary> + /// <param name="timedEvents"></param> + /// <param name="inputBuffer"></param> + /// <param name="inputProcessor"></param> + /// <param name="consoleOutput"></param> + void Initialize (ITimedEvents timedEvents, ConcurrentQueue<T> inputBuffer, IInputProcessor inputProcessor, IConsoleOutput consoleOutput); + + /// <summary> + /// Perform a single iteration of the main loop then blocks for a fixed length + /// of time, this method is designed to be run in a loop. + /// </summary> + public void Iteration (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IMainLoopCoordinator.cs b/Terminal.Gui/ConsoleDrivers/V2/IMainLoopCoordinator.cs new file mode 100644 index 0000000000..7a1ccf35bb --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IMainLoopCoordinator.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui; + +/// <summary> +/// Interface for main Terminal.Gui loop manager in v2. +/// </summary> +public interface IMainLoopCoordinator +{ + /// <summary> + /// Create all required subcomponents and boot strap. + /// </summary> + /// <returns></returns> + public Task StartAsync (); + + /// <summary> + /// Stops the input thread, blocking till it exits. + /// Call this method only from the main UI loop. + /// </summary> + public void Stop (); + + /// <summary> + /// Run a single iteration of the main UI loop + /// </summary> + void RunIteration (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/INetInput.cs b/Terminal.Gui/ConsoleDrivers/V2/INetInput.cs new file mode 100644 index 0000000000..610eee236b --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/INetInput.cs @@ -0,0 +1,4 @@ +namespace Terminal.Gui; + +internal interface INetInput : IConsoleInput<ConsoleKeyInfo> +{ } diff --git a/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs b/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs new file mode 100644 index 0000000000..29c3475efe --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs @@ -0,0 +1,122 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Describes the screen state that you want the console to be in. +/// Is designed to be drawn to repeatedly then manifest into the console +/// once at the end of iteration after all drawing is finalized. +/// </summary> +public interface IOutputBuffer +{ + /// <summary> + /// As performance is a concern, we keep track of the dirty lines and only refresh those. + /// This is in addition to the dirty flag on each cell. + /// </summary> + public bool [] DirtyLines { get; } + + /// <summary> + /// The contents of the application output. The driver outputs this buffer to the terminal when UpdateScreen is called. + /// </summary> + Cell [,] Contents { get; set; } + + /// <summary> + /// Gets or sets the clip rectangle that <see cref="AddRune(Rune)"/> and <see cref="AddStr(string)"/> are subject + /// to. + /// </summary> + /// <value>The rectangle describing the of <see cref="Clip"/> region.</value> + public Region? Clip { get; set; } + + /// <summary> + /// The <see cref="Attribute"/> that will be used for the next AddRune or AddStr call. + /// </summary> + Attribute CurrentAttribute { get; set; } + + /// <summary>The number of rows visible in the terminal.</summary> + int Rows { get; set; } + + /// <summary>The number of columns visible in the terminal.</summary> + int Cols { get; set; } + + /// <summary> + /// Gets the row last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Row { get; } + + /// <summary> + /// Gets the column last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Col { get; } + + /// <summary> + /// The first cell index on left of screen - basically always 0. + /// Changing this may have unexpected consequences. + /// </summary> + int Left { get; set; } + + /// <summary> + /// The first cell index on top of screen - basically always 0. + /// Changing this may have unexpected consequences. + /// </summary> + int Top { get; set; } + + /// <summary> + /// Updates the column and row to the specified location in the buffer. + /// </summary> + /// <param name="col">The column to move to.</param> + /// <param name="row">The row to move to.</param> + void Move (int col, int row); + + /// <summary>Adds the specified rune to the display at the current cursor position.</summary> + /// <param name="rune">Rune to add.</param> + void AddRune (Rune rune); + + /// <summary> + /// Adds the specified character to the display at the current cursor position. This is a convenience method for + /// AddRune. + /// </summary> + /// <param name="c">Character to add.</param> + void AddRune (char c); + + /// <summary>Adds the string to the display at the current cursor position.</summary> + /// <param name="str">String to add.</param> + void AddStr (string str); + + /// <summary>Clears the contents of the buffer.</summary> + void ClearContents (); + + /// <summary> + /// Tests whether the specified coordinate is valid for drawing the specified Rune. + /// </summary> + /// <param name="rune">Used to determine if one or two columns are required.</param> + /// <param name="col">The column.</param> + /// <param name="row">The row.</param> + /// <returns> + /// True if the coordinate is valid for the Rune; false otherwise. + /// </returns> + bool IsValidLocation (Rune rune, int col, int row); + + /// <summary> + /// Changes the size of the buffer to the given size + /// </summary> + /// <param name="cols"></param> + /// <param name="rows"></param> + void SetWindowSize (int cols, int rows); + + /// <summary> + /// Fills the given <paramref name="rect"/> with the given + /// symbol using the currently selected attribute. + /// </summary> + /// <param name="rect"></param> + /// <param name="rune"></param> + void FillRect (Rectangle rect, Rune rune); + + /// <summary> + /// Fills the given <paramref name="rect"/> with the given + /// symbol using the currently selected attribute. + /// </summary> + /// <param name="rect"></param> + /// <param name="rune"></param> + void FillRect (Rectangle rect, char rune); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IToplevelTransitionManager.cs b/Terminal.Gui/ConsoleDrivers/V2/IToplevelTransitionManager.cs new file mode 100644 index 0000000000..984c54490d --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IToplevelTransitionManager.cs @@ -0,0 +1,20 @@ +namespace Terminal.Gui; + +/// <summary> +/// Interface for class that handles bespoke behaviours that occur when application +/// top level changes. +/// </summary> +public interface IToplevelTransitionManager +{ + /// <summary> + /// Raises the <see cref="Toplevel.Ready"/> event on the current top level + /// if it has not been raised before now. + /// </summary> + void RaiseReadyEventIfNeeded (); + + /// <summary> + /// Handles any state change needed when the application top changes e.g. + /// setting redraw flags + /// </summary> + void HandleTopMaybeChanging (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs b/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs new file mode 100644 index 0000000000..5bfedfaf58 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Interface for classes responsible for reporting the current +/// size of the terminal window. +/// </summary> +public interface IWindowSizeMonitor +{ + /// <summary>Invoked when the terminal's size changed. The new size of the terminal is provided.</summary> + event EventHandler<SizeChangedEventArgs>? SizeChanging; + + /// <summary> + /// Examines the current size of the terminal and raises <see cref="SizeChanging"/> if it is different + /// from last inspection. + /// </summary> + /// <returns></returns> + bool Poll (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IWindowsInput.cs b/Terminal.Gui/ConsoleDrivers/V2/IWindowsInput.cs new file mode 100644 index 0000000000..63bf8e2933 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IWindowsInput.cs @@ -0,0 +1,4 @@ +namespace Terminal.Gui; + +internal interface IWindowsInput : IConsoleInput<WindowsConsole.InputRecord> +{ } diff --git a/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs new file mode 100644 index 0000000000..e7b7b8d2cc --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs @@ -0,0 +1,165 @@ +#nullable enable +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// <summary> +/// Processes the queued input buffer contents - which must be of Type <typeparamref name="T"/>. +/// Is responsible for <see cref="ProcessQueue"/> and translating into common Terminal.Gui +/// events and data models. +/// </summary> +public abstract class InputProcessor<T> : IInputProcessor +{ + /// <summary> + /// How long after Esc has been pressed before we give up on getting an Ansi escape sequence + /// </summary> + private readonly TimeSpan _escTimeout = TimeSpan.FromMilliseconds (50); + + internal AnsiResponseParser<T> Parser { get; } = new (); + + /// <summary> + /// Class responsible for translating the driver specific native input class <typeparamref name="T"/> e.g. + /// <see cref="ConsoleKeyInfo"/> into the Terminal.Gui <see cref="Key"/> class (used for all + /// internal library representations of Keys). + /// </summary> + public IKeyConverter<T> KeyConverter { get; } + + /// <summary> + /// Input buffer which will be drained from by this class. + /// </summary> + public ConcurrentQueue<T> InputBuffer { get; } + + /// <inheritdoc/> + public IAnsiResponseParser GetParser () { return Parser; } + + private readonly MouseInterpreter _mouseInterpreter = new (); + + /// <summary>Event fired when a key is pressed down. This is a precursor to <see cref="KeyUp"/>.</summary> + public event EventHandler<Key>? KeyDown; + + /// <summary>Event fired when a terminal sequence read from input is not recognized and therefore ignored.</summary> + public event EventHandler<string>? AnsiSequenceSwallowed; + + /// <summary> + /// Called when a key is pressed down. Fires the <see cref="KeyDown"/> event. This is a precursor to + /// <see cref="OnKeyUp"/>. + /// </summary> + /// <param name="a"></param> + public void OnKeyDown (Key a) + { + Logging.Trace ($"{nameof (InputProcessor<T>)} raised {a}"); + KeyDown?.Invoke (this, a); + } + + /// <summary>Event fired when a key is released.</summary> + /// <remarks> + /// Drivers that do not support key release events will fire this event after <see cref="KeyDown"/> processing is + /// complete. + /// </remarks> + public event EventHandler<Key>? KeyUp; + + /// <summary>Called when a key is released. Fires the <see cref="KeyUp"/> event.</summary> + /// <remarks> + /// Drivers that do not support key release events will call this method after <see cref="OnKeyDown"/> processing + /// is complete. + /// </remarks> + /// <param name="a"></param> + public void OnKeyUp (Key a) { KeyUp?.Invoke (this, a); } + + /// <summary>Event fired when a mouse event occurs.</summary> + public event EventHandler<MouseEventArgs>? MouseEvent; + + /// <summary>Called when a mouse event occurs. Fires the <see cref="MouseEvent"/> event.</summary> + /// <param name="a"></param> + public void OnMouseEvent (MouseEventArgs a) + { + // Ensure ScreenPosition is set + a.ScreenPosition = a.Position; + + foreach (MouseEventArgs e in _mouseInterpreter.Process (a)) + { + Logging.Trace ($"Mouse Interpreter raising {e.Flags}"); + + // Pass on + MouseEvent?.Invoke (this, e); + } + } + + /// <summary> + /// Constructs base instance including wiring all relevant + /// parser events and setting <see cref="InputBuffer"/> to + /// the provided thread safe input collection. + /// </summary> + /// <param name="inputBuffer">The collection that will be populated with new input (see <see cref="IConsoleInput{T}"/>)</param> + /// <param name="keyConverter"> + /// Key converter for translating driver specific + /// <typeparamref name="T"/> class into Terminal.Gui <see cref="Key"/>. + /// </param> + protected InputProcessor (ConcurrentQueue<T> inputBuffer, IKeyConverter<T> keyConverter) + { + InputBuffer = inputBuffer; + Parser.HandleMouse = true; + Parser.Mouse += (s, e) => OnMouseEvent (e); + + Parser.HandleKeyboard = true; + + Parser.Keyboard += (s, k) => + { + OnKeyDown (k); + OnKeyUp (k); + }; + + // TODO: For now handle all other escape codes with ignore + Parser.UnexpectedResponseHandler = str => + { + var cur = new string (str.Select (k => k.Item1).ToArray ()); + Logging.Logger.LogInformation ($"{nameof (InputProcessor<T>)} ignored unrecognized response '{cur}'"); + AnsiSequenceSwallowed?.Invoke (this, cur); + + return true; + }; + KeyConverter = keyConverter; + } + + /// <summary> + /// Drains the <see cref="InputBuffer"/> buffer, processing all available keystrokes + /// </summary> + public void ProcessQueue () + { + while (InputBuffer.TryDequeue (out T? input)) + { + Process (input); + } + + foreach (T input in ReleaseParserHeldKeysIfStale ()) + { + ProcessAfterParsing (input); + } + } + + private IEnumerable<T> ReleaseParserHeldKeysIfStale () + { + if (Parser.State is AnsiResponseParserState.ExpectingEscapeSequence or AnsiResponseParserState.InResponse + && DateTime.Now - Parser.StateChangedAt > _escTimeout) + { + return Parser.Release ().Select (o => o.Item2); + } + + return []; + } + + /// <summary> + /// Process the provided single input element <paramref name="input"/>. This method + /// is called sequentially for each value read from <see cref="InputBuffer"/>. + /// </summary> + /// <param name="input"></param> + protected abstract void Process (T input); + + /// <summary> + /// Process the provided single input element - short-circuiting the <see cref="Parser"/> + /// stage of the processing. + /// </summary> + /// <param name="input"></param> + protected abstract void ProcessAfterParsing (T input); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/Logging.cs b/Terminal.Gui/ConsoleDrivers/V2/Logging.cs new file mode 100644 index 0000000000..0d367f4a40 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/Logging.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Terminal.Gui; + +/// <summary> +/// Singleton logging instance class. Do not use console loggers +/// with this class as it will interfere with Terminal.Gui +/// screen output (i.e. use a file logger). +/// </summary> +/// <remarks> +/// Also contains the +/// <see cref="Meter"/> instance that should be used for internal metrics +/// (iteration timing etc). +/// </remarks> +public static class Logging +{ + /// <summary> + /// Logger, defaults to NullLogger (i.e. no logging). Set this to a + /// file logger to enable logging of Terminal.Gui internals. + /// </summary> + public static ILogger Logger { get; set; } = NullLogger.Instance; + + /// <summary> + /// Metrics reporting meter for internal Terminal.Gui processes. To use + /// create your own static instrument e.g. CreateCounter, CreateHistogram etc + /// </summary> + internal static readonly Meter Meter = new ("Terminal.Gui"); + + /// <summary> + /// Metric for how long it takes each full iteration of the main loop to occur + /// </summary> + public static readonly Histogram<int> TotalIterationMetric = Meter.CreateHistogram<int> ("Iteration (ms)"); + + /// <summary> + /// Metric for how long it took to do the 'timeouts and invokes' section of main loop. + /// </summary> + public static readonly Histogram<int> IterationInvokesAndTimeouts = Meter.CreateHistogram<int> ("Invokes & Timers (ms)"); + + /// <summary> + /// Counter for when we redraw, helps detect situations e.g. where we are repainting entire UI every loop + /// </summary> + public static readonly Counter<int> Redraws = Meter.CreateCounter<int> ("Redraws"); + + /// <summary> + /// Metric for how long it takes to read all available input from the input stream - at which + /// point input loop will sleep. + /// </summary> + public static readonly Histogram<int> DrainInputStream = Meter.CreateHistogram<int> ("Drain Input (ms)"); + + /// <summary> + /// Logs a trace message including the + /// </summary> + /// <param name="message"></param> + /// <param name="caller"></param> + /// <param name="filePath"></param> + public static void Trace ( + string message, + [CallerMemberName] string caller = "", + [CallerFilePath] string filePath = "" + ) + { + string className = Path.GetFileNameWithoutExtension (filePath); + Logger.LogTrace ($"[{className}] [{caller}] {message}"); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs b/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs new file mode 100644 index 0000000000..8c830aaa36 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs @@ -0,0 +1,203 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Terminal.Gui; + +/// <inheritdoc/> +public class MainLoop<T> : IMainLoop<T> +{ + private ITimedEvents? _timedEvents; + private ConcurrentQueue<T>? _inputBuffer; + private IInputProcessor? _inputProcessor; + private IConsoleOutput? _out; + private AnsiRequestScheduler? _ansiRequestScheduler; + private IWindowSizeMonitor? _windowSizeMonitor; + + /// <inheritdoc/> + public ITimedEvents TimedEvents + { + get => _timedEvents ?? throw new NotInitializedException (nameof (TimedEvents)); + private set => _timedEvents = value; + } + + // TODO: follow above pattern for others too + + /// <summary> + /// The input events thread-safe collection. This is populated on separate + /// thread by a <see cref="IConsoleInput{T}"/>. Is drained as part of each + /// <see cref="Iteration"/> + /// </summary> + public ConcurrentQueue<T> InputBuffer + { + get => _inputBuffer ?? throw new NotInitializedException (nameof (InputBuffer)); + private set => _inputBuffer = value; + } + + /// <inheritdoc/> + public IInputProcessor InputProcessor + { + get => _inputProcessor ?? throw new NotInitializedException (nameof (InputProcessor)); + private set => _inputProcessor = value; + } + + /// <inheritdoc/> + public IOutputBuffer OutputBuffer { get; } = new OutputBuffer (); + + /// <inheritdoc/> + public IConsoleOutput Out + { + get => _out ?? throw new NotInitializedException (nameof (Out)); + private set => _out = value; + } + + /// <inheritdoc/> + public AnsiRequestScheduler AnsiRequestScheduler + { + get => _ansiRequestScheduler ?? throw new NotInitializedException (nameof (AnsiRequestScheduler)); + private set => _ansiRequestScheduler = value; + } + + /// <inheritdoc/> + public IWindowSizeMonitor WindowSizeMonitor + { + get => _windowSizeMonitor ?? throw new NotInitializedException (nameof (WindowSizeMonitor)); + private set => _windowSizeMonitor = value; + } + + /// <summary> + /// Handles raising events and setting required draw status etc when <see cref="Application.Top"/> changes + /// </summary> + public IToplevelTransitionManager ToplevelTransitionManager = new ToplevelTransitionManager (); + + /// <summary> + /// Determines how to get the current system type, adjust + /// in unit tests to simulate specific timings. + /// </summary> + public Func<DateTime> Now { get; set; } = () => DateTime.Now; + + /// <summary> + /// Initializes the class with the provided subcomponents + /// </summary> + /// <param name="timedEvents"></param> + /// <param name="inputBuffer"></param> + /// <param name="inputProcessor"></param> + /// <param name="consoleOutput"></param> + public void Initialize (ITimedEvents timedEvents, ConcurrentQueue<T> inputBuffer, IInputProcessor inputProcessor, IConsoleOutput consoleOutput) + { + InputBuffer = inputBuffer; + Out = consoleOutput; + InputProcessor = inputProcessor; + + TimedEvents = timedEvents; + AnsiRequestScheduler = new (InputProcessor.GetParser ()); + + WindowSizeMonitor = new WindowSizeMonitor (Out, OutputBuffer); + } + + /// <inheritdoc/> + public void Iteration () + { + DateTime dt = Now (); + + IterationImpl (); + + TimeSpan took = Now () - dt; + TimeSpan sleepFor = TimeSpan.FromMilliseconds (50) - took; + + Logging.TotalIterationMetric.Record (took.Milliseconds); + + if (sleepFor.Milliseconds > 0) + { + Task.Delay (sleepFor).Wait (); + } + } + + internal void IterationImpl () + { + InputProcessor.ProcessQueue (); + + ToplevelTransitionManager.RaiseReadyEventIfNeeded (); + ToplevelTransitionManager.HandleTopMaybeChanging (); + + if (Application.Top != null) + { + bool needsDrawOrLayout = AnySubviewsNeedDrawn (Application.Top); + + bool sizeChanged = WindowSizeMonitor.Poll (); + + if (needsDrawOrLayout || sizeChanged) + { + Logging.Redraws.Add (1); + + // TODO: Test only + Application.LayoutAndDraw (true); + + Out.Write (OutputBuffer); + + Out.SetCursorVisibility (CursorVisibility.Default); + } + + SetCursor (); + } + + var swCallbacks = Stopwatch.StartNew (); + + TimedEvents.LockAndRunTimers (); + + TimedEvents.LockAndRunIdles (); + + Logging.IterationInvokesAndTimeouts.Record (swCallbacks.Elapsed.Milliseconds); + } + + private void SetCursor () + { + View? mostFocused = Application.Top.MostFocused; + + if (mostFocused == null) + { + return; + } + + Point? to = mostFocused.PositionCursor (); + + if (to.HasValue) + { + // Translate to screen coordinates + to = mostFocused.ViewportToScreen (to.Value); + + Out.SetCursorPosition (to.Value.X, to.Value.Y); + Out.SetCursorVisibility (mostFocused.CursorVisibility); + } + else + { + Out.SetCursorVisibility (CursorVisibility.Invisible); + } + } + + private bool AnySubviewsNeedDrawn (View v) + { + if (v.NeedsDraw || v.NeedsLayout) + { + Logging.Trace ($"{v.GetType ().Name} triggered redraw (NeedsDraw={v.NeedsDraw} NeedsLayout={v.NeedsLayout}) "); + + return true; + } + + foreach (View subview in v.Subviews) + { + if (AnySubviewsNeedDrawn (subview)) + { + return true; + } + } + + return false; + } + + /// <inheritdoc/> + public void Dispose () + { + // TODO release managed resources here + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/MainLoopCoordinator.cs b/Terminal.Gui/ConsoleDrivers/V2/MainLoopCoordinator.cs new file mode 100644 index 0000000000..28bad67ab4 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MainLoopCoordinator.cs @@ -0,0 +1,186 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// <summary> +/// <para> +/// Handles creating the input loop thread and bootstrapping the +/// <see cref="MainLoop{T}"/> that handles layout/drawing/events etc. +/// </para> +/// <para>This class is designed to be managed by <see cref="ApplicationV2"/></para> +/// </summary> +/// <typeparam name="T"></typeparam> +internal class MainLoopCoordinator<T> : IMainLoopCoordinator +{ + private readonly Func<IConsoleInput<T>> _inputFactory; + private readonly ConcurrentQueue<T> _inputBuffer; + private readonly IInputProcessor _inputProcessor; + private readonly IMainLoop<T> _loop; + private readonly CancellationTokenSource _tokenSource = new (); + private readonly Func<IConsoleOutput> _outputFactory; + private IConsoleInput<T> _input; + private IConsoleOutput _output; + private readonly object _oLockInitialization = new (); + private ConsoleDriverFacade<T> _facade; + private Task _inputTask; + private readonly ITimedEvents _timedEvents; + + private readonly SemaphoreSlim _startupSemaphore = new (0, 1); + + /// <summary> + /// Creates a new coordinator + /// </summary> + /// <param name="timedEvents"></param> + /// <param name="inputFactory"> + /// Function to create a new input. This must call <see langword="new"/> + /// explicitly and cannot return an existing instance. This requirement arises because Windows + /// console screen buffer APIs are thread-specific for certain operations. + /// </param> + /// <param name="inputBuffer"></param> + /// <param name="inputProcessor"></param> + /// <param name="outputFactory"> + /// Function to create a new output. This must call <see langword="new"/> + /// explicitly and cannot return an existing instance. This requirement arises because Windows + /// console screen buffer APIs are thread-specific for certain operations. + /// </param> + /// <param name="loop"></param> + public MainLoopCoordinator ( + ITimedEvents timedEvents, + Func<IConsoleInput<T>> inputFactory, + ConcurrentQueue<T> inputBuffer, + IInputProcessor inputProcessor, + Func<IConsoleOutput> outputFactory, + IMainLoop<T> loop + ) + { + _timedEvents = timedEvents; + _inputFactory = inputFactory; + _inputBuffer = inputBuffer; + _inputProcessor = inputProcessor; + _outputFactory = outputFactory; + _loop = loop; + } + + /// <summary> + /// Starts the input loop thread in separate task (returning immediately). + /// </summary> + public async Task StartAsync () + { + Logging.Logger.LogInformation ("Main Loop Coordinator booting..."); + + _inputTask = Task.Run (RunInput); + + // Main loop is now booted on same thread as rest of users application + BootMainLoop (); + + // Wait asynchronously for the semaphore or task failure. + Task waitForSemaphore = _startupSemaphore.WaitAsync (); + + // Wait for either the semaphore to be released or the input task to crash. + Task completedTask = await Task.WhenAny (waitForSemaphore, _inputTask).ConfigureAwait (false); + + // Check if the task was the input task and if it has failed. + if (completedTask == _inputTask) + { + if (_inputTask.IsFaulted) + { + throw _inputTask.Exception; + } + + throw new ("Input loop exited during startup instead of entering read loop properly (i.e. and blocking)"); + } + + Logging.Logger.LogInformation ("Main Loop Coordinator booting complete"); + } + + private void RunInput () + { + try + { + lock (_oLockInitialization) + { + // Instance must be constructed on the thread in which it is used. + _input = _inputFactory.Invoke (); + _input.Initialize (_inputBuffer); + + BuildFacadeIfPossible (); + } + + try + { + _input.Run (_tokenSource.Token); + } + catch (OperationCanceledException) + { } + + _input.Dispose (); + } + catch (Exception e) + { + Logging.Logger.LogCritical (e, "Input loop crashed"); + + throw; + } + + if (_stopCalled) + { + Logging.Logger.LogInformation ("Input loop exited cleanly"); + } + else + { + Logging.Logger.LogCritical ("Input loop exited early (stop not called)"); + } + } + + /// <inheritdoc/> + public void RunIteration () { _loop.Iteration (); } + + private void BootMainLoop () + { + lock (_oLockInitialization) + { + // Instance must be constructed on the thread in which it is used. + _output = _outputFactory.Invoke (); + _loop.Initialize (_timedEvents, _inputBuffer, _inputProcessor, _output); + + BuildFacadeIfPossible (); + } + } + + private void BuildFacadeIfPossible () + { + if (_input != null && _output != null) + { + _facade = new ( + _inputProcessor, + _loop.OutputBuffer, + _output, + _loop.AnsiRequestScheduler, + _loop.WindowSizeMonitor); + Application.Driver = _facade; + + _startupSemaphore.Release (); + } + } + + private bool _stopCalled; + + /// <inheritdoc/> + public void Stop () + { + // Ignore repeated calls to Stop - happens if user spams Application.Shutdown(). + if (_stopCalled) + { + return; + } + + _stopCalled = true; + + _tokenSource.Cancel (); + _output.Dispose (); + + // Wait for input infinite loop to exit + _inputTask.Wait (); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/MouseButtonStateEx.cs b/Terminal.Gui/ConsoleDrivers/V2/MouseButtonStateEx.cs new file mode 100644 index 0000000000..fb1aa27fee --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MouseButtonStateEx.cs @@ -0,0 +1,89 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Not to be confused with <see cref="NetEvents.MouseButtonState"/> +/// </summary> +internal class MouseButtonStateEx +{ + private readonly Func<DateTime> _now; + private readonly TimeSpan _repeatClickThreshold; + private readonly int _buttonIdx; + private int _consecutiveClicks; + private Point _lastPosition; + + /// <summary> + /// When the button entered its current state. + /// </summary> + public DateTime At { get; set; } + + /// <summary> + /// <see langword="true"/> if the button is currently down + /// </summary> + public bool Pressed { get; set; } + + public MouseButtonStateEx (Func<DateTime> now, TimeSpan repeatClickThreshold, int buttonIdx) + { + _now = now; + _repeatClickThreshold = repeatClickThreshold; + _buttonIdx = buttonIdx; + } + + public void UpdateState (MouseEventArgs e, out int? numClicks) + { + bool isPressedNow = IsPressed (_buttonIdx, e.Flags); + bool isSamePosition = _lastPosition == e.Position; + + TimeSpan elapsed = _now () - At; + + if (elapsed > _repeatClickThreshold || !isSamePosition) + { + // Expired + OverwriteState (e); + _consecutiveClicks = 0; + numClicks = null; + } + else + { + if (isPressedNow == Pressed) + { + // No change in button state so do nothing + numClicks = null; + + return; + } + + if (Pressed) + { + // Click released + numClicks = ++_consecutiveClicks; + } + else + { + numClicks = null; + } + + // Record new state + OverwriteState (e); + } + } + + private void OverwriteState (MouseEventArgs e) + { + Pressed = IsPressed (_buttonIdx, e.Flags); + At = _now (); + _lastPosition = e.Position; + } + + private bool IsPressed (int btn, MouseFlags eFlags) + { + return btn switch + { + 0 => eFlags.HasFlag (MouseFlags.Button1Pressed), + 1 => eFlags.HasFlag (MouseFlags.Button2Pressed), + 2 => eFlags.HasFlag (MouseFlags.Button3Pressed), + 3 => eFlags.HasFlag (MouseFlags.Button4Pressed), + _ => throw new ArgumentOutOfRangeException (nameof (btn)) + }; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/MouseInterpreter.cs b/Terminal.Gui/ConsoleDrivers/V2/MouseInterpreter.cs new file mode 100644 index 0000000000..f6546302e1 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MouseInterpreter.cs @@ -0,0 +1,105 @@ +#nullable enable + +namespace Terminal.Gui; + +internal class MouseInterpreter +{ + /// <summary> + /// Function for returning the current time. Use in unit tests to + /// ensure repeatable tests. + /// </summary> + public Func<DateTime> Now { get; set; } + + /// <summary> + /// How long to wait for a second, third, fourth click after the first before giving up and + /// releasing event as a 'click' + /// </summary> + public TimeSpan RepeatedClickThreshold { get; set; } + + private readonly MouseButtonStateEx [] _buttonStates; + + public MouseInterpreter ( + Func<DateTime>? now = null, + TimeSpan? doubleClickThreshold = null + ) + { + Now = now ?? (() => DateTime.Now); + RepeatedClickThreshold = doubleClickThreshold ?? TimeSpan.FromMilliseconds (500); + + _buttonStates = new [] + { + new MouseButtonStateEx (Now, RepeatedClickThreshold, 0), + new MouseButtonStateEx (Now, RepeatedClickThreshold, 1), + new MouseButtonStateEx (Now, RepeatedClickThreshold, 2), + new MouseButtonStateEx (Now, RepeatedClickThreshold, 3) + }; + } + + public IEnumerable<MouseEventArgs> Process (MouseEventArgs e) + { + yield return e; + + // For each mouse button + for (var i = 0; i < 4; i++) + { + _buttonStates [i].UpdateState (e, out int? numClicks); + + if (numClicks.HasValue) + { + yield return RaiseClick (i, numClicks.Value, e); + } + } + } + + private MouseEventArgs RaiseClick (int button, int numberOfClicks, MouseEventArgs mouseEventArgs) + { + var newClick = new MouseEventArgs + { + Handled = false, + Flags = ToClicks (button, numberOfClicks), + ScreenPosition = mouseEventArgs.ScreenPosition, + View = mouseEventArgs.View, + Position = mouseEventArgs.Position + }; + Logging.Trace ($"Raising click event:{newClick.Flags} at screen {newClick.ScreenPosition}"); + + return newClick; + } + + private MouseFlags ToClicks (int buttonIdx, int numberOfClicks) + { + if (numberOfClicks == 0) + { + throw new ArgumentOutOfRangeException (nameof (numberOfClicks), "Zero clicks are not valid."); + } + + return buttonIdx switch + { + 0 => numberOfClicks switch + { + 1 => MouseFlags.Button1Clicked, + 2 => MouseFlags.Button1DoubleClicked, + _ => MouseFlags.Button1TripleClicked + }, + 1 => numberOfClicks switch + { + 1 => MouseFlags.Button2Clicked, + 2 => MouseFlags.Button2DoubleClicked, + _ => MouseFlags.Button2TripleClicked + }, + 2 => numberOfClicks switch + { + 1 => MouseFlags.Button3Clicked, + 2 => MouseFlags.Button3DoubleClicked, + _ => MouseFlags.Button3TripleClicked + }, + 3 => numberOfClicks switch + { + 1 => MouseFlags.Button4Clicked, + 2 => MouseFlags.Button4DoubleClicked, + _ => MouseFlags.Button4TripleClicked + }, + _ => throw new ArgumentOutOfRangeException (nameof (buttonIdx), "Unsupported button index") + }; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetInput.cs b/Terminal.Gui/ConsoleDrivers/V2/NetInput.cs new file mode 100644 index 0000000000..d263c57493 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetInput.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// <summary> +/// Console input implementation that uses native dotnet methods e.g. <see cref="System.Console"/>. +/// </summary> +public class NetInput : ConsoleInput<ConsoleKeyInfo>, INetInput +{ + private readonly NetWinVTConsole _adjustConsole; + + /// <summary> + /// Creates a new instance of the class. Implicitly sends + /// console mode settings that enable virtual input (mouse + /// reporting etc). + /// </summary> + public NetInput () + { + Logging.Logger.LogInformation ($"Creating {nameof (NetInput)}"); + PlatformID p = Environment.OSVersion.Platform; + + if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + { + try + { + _adjustConsole = new (); + } + catch (ApplicationException ex) + { + // Likely running as a unit test, or in a non-interactive session. + Logging.Logger.LogCritical ( + ex, + "NetWinVTConsole could not be constructed i.e. could not configure terminal modes. May indicate running in non-interactive session e.g. unit testing CI"); + } + } + + Console.Out.Write (EscSeqUtils.CSI_EnableMouseEvents); + Console.TreatControlCAsInput = true; + } + + /// <inheritdoc/> + protected override bool Peek () { return Console.KeyAvailable; } + + /// <inheritdoc/> + protected override IEnumerable<ConsoleKeyInfo> Read () + { + while (Console.KeyAvailable) + { + yield return Console.ReadKey (true); + } + } + + /// <inheritdoc/> + public override void Dispose () + { + base.Dispose (); + _adjustConsole?.Cleanup (); + + Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs new file mode 100644 index 0000000000..d08daca5f4 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// <summary> +/// Input processor for <see cref="NetInput"/>, deals in <see cref="ConsoleKeyInfo"/> stream +/// </summary> +public class NetInputProcessor : InputProcessor<ConsoleKeyInfo> +{ +#pragma warning disable CA2211 + /// <summary> + /// Set to true to generate code in <see cref="Logging"/> (verbose only) for test cases in NetInputProcessorTests. + /// <remarks> + /// This makes the task of capturing user/language/terminal specific keyboard issues easier to + /// diagnose. By turning this on and searching logs user can send us exactly the input codes that are released + /// to input stream. + /// </remarks> + /// </summary> + public static bool GenerateTestCasesForKeyPresses = false; +#pragma warning enable CA2211 + + /// <inheritdoc/> + public NetInputProcessor (ConcurrentQueue<ConsoleKeyInfo> inputBuffer) : base (inputBuffer, new NetKeyConverter ()) { } + + /// <inheritdoc/> + protected override void Process (ConsoleKeyInfo consoleKeyInfo) + { + // For building test cases + if (GenerateTestCasesForKeyPresses) + { + Logging.Trace (FormatConsoleKeyInfoForTestCase (consoleKeyInfo)); + } + + foreach (Tuple<char, ConsoleKeyInfo> released in Parser.ProcessInput (Tuple.Create (consoleKeyInfo.KeyChar, consoleKeyInfo))) + { + ProcessAfterParsing (released.Item2); + } + } + + /// <inheritdoc/> + protected override void ProcessAfterParsing (ConsoleKeyInfo input) + { + var key = KeyConverter.ToKey (input); + OnKeyDown (key); + OnKeyUp (key); + } + + /* For building test cases */ + private static string FormatConsoleKeyInfoForTestCase (ConsoleKeyInfo input) + { + string charLiteral = input.KeyChar == '\0' ? @"'\0'" : $"'{input.KeyChar}'"; + var expectedLiteral = "new Rune('todo')"; + + return $"new ConsoleKeyInfo({charLiteral}, ConsoleKey.{input.Key}, " + + $"{input.Modifiers.HasFlag (ConsoleModifiers.Shift).ToString ().ToLower ()}, " + + $"{input.Modifiers.HasFlag (ConsoleModifiers.Alt).ToString ().ToLower ()}, " + + $"{input.Modifiers.HasFlag (ConsoleModifiers.Control).ToString ().ToLower ()}), {expectedLiteral}"; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs b/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs new file mode 100644 index 0000000000..b7c17e6153 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs @@ -0,0 +1,25 @@ +namespace Terminal.Gui; + +/// <summary> +/// <see cref="IKeyConverter{T}"/> capable of converting the +/// dotnet <see cref="ConsoleKeyInfo"/> class into Terminal.Gui +/// shared <see cref="Key"/> representation (used by <see cref="View"/> +/// etc). +/// </summary> +internal class NetKeyConverter : IKeyConverter<ConsoleKeyInfo> +{ + /// <inheritdoc/> + public Key ToKey (ConsoleKeyInfo input) + { + ConsoleKeyInfo adjustedInput = EscSeqUtils.MapConsoleKeyInfo (input); + + // TODO : EscSeqUtils.MapConsoleKeyInfo is wrong for e.g. '{' - it winds up clearing the Key + // So if the method nuked it then we should just work with the original. + if (adjustedInput.Key == ConsoleKey.None && input.Key != ConsoleKey.None) + { + return EscSeqUtils.MapKey (input); + } + + return EscSeqUtils.MapKey (adjustedInput); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetOutput.cs b/Terminal.Gui/ConsoleDrivers/V2/NetOutput.cs new file mode 100644 index 0000000000..b7a9ea2806 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetOutput.cs @@ -0,0 +1,249 @@ +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// <summary> +/// Implementation of <see cref="IConsoleOutput"/> that uses native dotnet +/// methods e.g. <see cref="System.Console"/> +/// </summary> +public class NetOutput : IConsoleOutput +{ + private readonly bool _isWinPlatform; + + private CursorVisibility? _cachedCursorVisibility; + + /// <summary> + /// Creates a new instance of the <see cref="NetOutput"/> class. + /// </summary> + public NetOutput () + { + Logging.Logger.LogInformation ($"Creating {nameof (NetOutput)}"); + + PlatformID p = Environment.OSVersion.Platform; + + if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + { + _isWinPlatform = true; + } + + //Enable alternative screen buffer. + Console.Out.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); + + //Set cursor key to application. + Console.Out.Write (EscSeqUtils.CSI_HideCursor); + } + + /// <inheritdoc/> + public void Write (string text) { Console.Write (text); } + + /// <inheritdoc/> + public void Write (IOutputBuffer buffer) + { + if (Console.WindowHeight < 1 + || buffer.Contents.Length != buffer.Rows * buffer.Cols + || buffer.Rows != Console.WindowHeight) + { + // return; + } + + var top = 0; + var left = 0; + int rows = buffer.Rows; + int cols = buffer.Cols; + var output = new StringBuilder (); + Attribute? redrawAttr = null; + int lastCol = -1; + + CursorVisibility? savedVisibility = _cachedCursorVisibility; + SetCursorVisibility (CursorVisibility.Invisible); + + for (int row = top; row < rows; row++) + { + if (Console.WindowHeight < 1) + { + return; + } + + if (!buffer.DirtyLines [row]) + { + continue; + } + + if (!SetCursorPositionImpl (0, row)) + { + return; + } + + buffer.DirtyLines [row] = false; + output.Clear (); + + for (int col = left; col < cols; col++) + { + lastCol = -1; + var outputWidth = 0; + + for (; col < cols; col++) + { + if (!buffer.Contents [row, col].IsDirty) + { + if (output.Length > 0) + { + WriteToConsole (output, ref lastCol, row, ref outputWidth); + } + else if (lastCol == -1) + { + lastCol = col; + } + + if (lastCol + 1 < cols) + { + lastCol++; + } + + continue; + } + + if (lastCol == -1) + { + lastCol = col; + } + + Attribute attr = buffer.Contents [row, col].Attribute.Value; + + // Performance: Only send the escape sequence if the attribute has changed. + if (attr != redrawAttr) + { + redrawAttr = attr; + + output.Append ( + EscSeqUtils.CSI_SetForegroundColorRGB ( + attr.Foreground.R, + attr.Foreground.G, + attr.Foreground.B + ) + ); + + output.Append ( + EscSeqUtils.CSI_SetBackgroundColorRGB ( + attr.Background.R, + attr.Background.G, + attr.Background.B + ) + ); + } + + outputWidth++; + Rune rune = buffer.Contents [row, col].Rune; + output.Append (rune); + + if (buffer.Contents [row, col].CombiningMarks.Count > 0) + { + // AtlasEngine does not support NON-NORMALIZED combining marks in a way + // compatible with the driver architecture. Any CMs (except in the first col) + // are correctly combined with the base char, but are ALSO treated as 1 column + // width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é ]`. + // + // For now, we just ignore the list of CMs. + //foreach (var combMark in Contents [row, col].CombiningMarks) { + // output.Append (combMark); + //} + // WriteToConsole (output, ref lastCol, row, ref outputWidth); + } + else if (rune.IsSurrogatePair () && rune.GetColumns () < 2) + { + WriteToConsole (output, ref lastCol, row, ref outputWidth); + SetCursorPositionImpl (col - 1, row); + } + + buffer.Contents [row, col].IsDirty = false; + } + } + + if (output.Length > 0) + { + SetCursorPositionImpl (lastCol, row); + Console.Write (output); + } + } + + foreach (SixelToRender s in Application.Sixel) + { + if (!string.IsNullOrWhiteSpace (s.SixelData)) + { + SetCursorPositionImpl (s.ScreenPosition.X, s.ScreenPosition.Y); + Console.Write (s.SixelData); + } + } + + SetCursorVisibility (savedVisibility ?? CursorVisibility.Default); + _cachedCursorVisibility = savedVisibility; + } + + /// <inheritdoc/> + public Size GetWindowSize () { return new (Console.WindowWidth, Console.WindowHeight); } + + private void WriteToConsole (StringBuilder output, ref int lastCol, int row, ref int outputWidth) + { + SetCursorPositionImpl (lastCol, row); + Console.Write (output); + output.Clear (); + lastCol += outputWidth; + outputWidth = 0; + } + + /// <inheritdoc/> + public void SetCursorPosition (int col, int row) { SetCursorPositionImpl (col, row); } + + private Point _lastCursorPosition; + + private bool SetCursorPositionImpl (int col, int row) + { + if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row) + { + return true; + } + + _lastCursorPosition = new (col, row); + + if (_isWinPlatform) + { + // Could happens that the windows is still resizing and the col is bigger than Console.WindowWidth. + try + { + Console.SetCursorPosition (col, row); + + return true; + } + catch (Exception) + { + return false; + } + } + + // + 1 is needed because non-Windows is based on 1 instead of 0 and + // Console.CursorTop/CursorLeft isn't reliable. + Console.Out.Write (EscSeqUtils.CSI_SetCursorPosition (row + 1, col + 1)); + + return true; + } + + /// <inheritdoc/> + public void Dispose () + { + Console.ResetColor (); + + //Disable alternative screen buffer. + Console.Out.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + + //Set cursor key to cursor. + Console.Out.Write (EscSeqUtils.CSI_ShowCursor); + + Console.Out.Close (); + } + + /// <inheritdoc/> + public void SetCursorVisibility (CursorVisibility visibility) + { + Console.Out.Write (visibility == CursorVisibility.Default ? EscSeqUtils.CSI_ShowCursor : EscSeqUtils.CSI_HideCursor); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/NotInitializedException.cs b/Terminal.Gui/ConsoleDrivers/V2/NotInitializedException.cs new file mode 100644 index 0000000000..5c63b4071e --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NotInitializedException.cs @@ -0,0 +1,22 @@ +namespace Terminal.Gui; + +/// <summary> +/// Thrown when user code attempts to access a property or perform a method +/// that is only supported after Initialization e.g. of an <see cref="IMainLoop{T}"/> +/// </summary> +public class NotInitializedException : Exception +{ + /// <summary> + /// Creates a new instance of the exception indicating that the class + /// <paramref name="memberName"/> cannot be used until owner is initialized. + /// </summary> + /// <param name="memberName">Property or method name</param> + public NotInitializedException (string memberName) : base ($"{memberName} cannot be accessed before Initialization") { } + + /// <summary> + /// Creates a new instance of the exception with the full message/inner exception. + /// </summary> + /// <param name="msg"></param> + /// <param name="innerException"></param> + public NotInitializedException (string msg, Exception innerException) : base (msg, innerException) { } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/OutputBuffer.cs b/Terminal.Gui/ConsoleDrivers/V2/OutputBuffer.cs new file mode 100644 index 0000000000..44d137ff32 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/OutputBuffer.cs @@ -0,0 +1,449 @@ +#nullable enable +using System.Diagnostics; + +namespace Terminal.Gui; + +/// <summary> +/// Stores the desired output state for the whole application. This is updated during +/// draw operations before being flushed to the console as part of <see cref="MainLoop{T}"/> +/// operation +/// </summary> +public class OutputBuffer : IOutputBuffer +{ + /// <summary> + /// The contents of the application output. The driver outputs this buffer to the terminal when + /// UpdateScreen is called. + /// <remarks>The format of the array is rows, columns. The first index is the row, the second index is the column.</remarks> + /// </summary> + public Cell [,] Contents { get; set; } = new Cell[0, 0]; + + private Attribute _currentAttribute; + private int _cols; + private int _rows; + + /// <summary> + /// The <see cref="Attribute"/> that will be used for the next <see cref="AddRune(Rune)"/> or <see cref="AddStr"/> + /// call. + /// </summary> + public Attribute CurrentAttribute + { + get => _currentAttribute; + set + { + // TODO: This makes IConsoleDriver dependent on Application, which is not ideal. Once Attribute.PlatformColor is removed, this can be fixed. + if (Application.Driver is { }) + { + _currentAttribute = new (value.Foreground, value.Background); + + return; + } + + _currentAttribute = value; + } + } + + /// <summary>The leftmost column in the terminal.</summary> + public virtual int Left { get; set; } = 0; + + /// <summary> + /// Gets the row last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Row { get; private set; } + + /// <summary> + /// Gets the column last set by <see cref="Move"/>. <see cref="Col"/> and <see cref="Row"/> are used by + /// <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + public int Col { get; private set; } + + /// <summary>The number of rows visible in the terminal.</summary> + public int Rows + { + get => _rows; + set + { + _rows = value; + ClearContents (); + } + } + + /// <summary>The number of columns visible in the terminal.</summary> + public int Cols + { + get => _cols; + set + { + _cols = value; + ClearContents (); + } + } + + /// <summary>The topmost row in the terminal.</summary> + public virtual int Top { get; set; } = 0; + + /// <inheritdoc/> + public bool [] DirtyLines { get; set; } = []; + + // QUESTION: When non-full screen apps are supported, will this represent the app size, or will that be in Application? + /// <summary>Gets the location and size of the terminal screen.</summary> + internal Rectangle Screen => new (0, 0, Cols, Rows); + + private Region? _clip; + + /// <summary> + /// Gets or sets the clip rectangle that <see cref="AddRune(Rune)"/> and <see cref="AddStr(string)"/> are subject + /// to. + /// </summary> + /// <value>The rectangle describing the of <see cref="Clip"/> region.</value> + public Region? Clip + { + get => _clip; + set + { + if (_clip == value) + { + return; + } + + _clip = value; + + // Don't ever let Clip be bigger than Screen + if (_clip is { }) + { + _clip.Intersect (Screen); + } + } + } + + /// <summary>Adds the specified rune to the display at the current cursor position.</summary> + /// <remarks> + /// <para> + /// When the method returns, <see cref="Col"/> will be incremented by the number of columns + /// <paramref name="rune"/> required, even if the new column value is outside of the <see cref="Clip"/> or screen + /// dimensions defined by <see cref="Cols"/>. + /// </para> + /// <para> + /// If <paramref name="rune"/> requires more than one column, and <see cref="Col"/> plus the number of columns + /// needed exceeds the <see cref="Clip"/> or screen dimensions, the default Unicode replacement character (U+FFFD) + /// will be added instead. + /// </para> + /// </remarks> + /// <param name="rune">Rune to add.</param> + public void AddRune (Rune rune) + { + int runeWidth = -1; + bool validLocation = IsValidLocation (rune, Col, Row); + + if (Contents is null) + { + return; + } + + Rectangle clipRect = Clip!.GetBounds (); + + if (validLocation) + { + rune = rune.MakePrintable (); + runeWidth = rune.GetColumns (); + + lock (Contents) + { + if (runeWidth == 0 && rune.IsCombiningMark ()) + { + // AtlasEngine does not support NON-NORMALIZED combining marks in a way + // compatible with the driver architecture. Any CMs (except in the first col) + // are correctly combined with the base char, but are ALSO treated as 1 column + // width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é ]`. + // + // Until this is addressed (see Issue #), we do our best by + // a) Attempting to normalize any CM with the base char to it's left + // b) Ignoring any CMs that don't normalize + if (Col > 0) + { + if (Contents [Row, Col - 1].CombiningMarks.Count > 0) + { + // Just add this mark to the list + Contents [Row, Col - 1].CombiningMarks.Add (rune); + + // Ignore. Don't move to next column (let the driver figure out what to do). + } + else + { + // Attempt to normalize the cell to our left combined with this mark + string combined = Contents [Row, Col - 1].Rune + rune.ToString (); + + // Normalize to Form C (Canonical Composition) + string normalized = combined.Normalize (NormalizationForm.FormC); + + if (normalized.Length == 1) + { + // It normalized! We can just set the Cell to the left with the + // normalized codepoint + Contents [Row, Col - 1].Rune = (Rune)normalized [0]; + + // Ignore. Don't move to next column because we're already there + } + else + { + // It didn't normalize. Add it to the Cell to left's CM list + Contents [Row, Col - 1].CombiningMarks.Add (rune); + + // Ignore. Don't move to next column (let the driver figure out what to do). + } + } + + Contents [Row, Col - 1].Attribute = CurrentAttribute; + Contents [Row, Col - 1].IsDirty = true; + } + else + { + // Most drivers will render a combining mark at col 0 as the mark + Contents [Row, Col].Rune = rune; + Contents [Row, Col].Attribute = CurrentAttribute; + Contents [Row, Col].IsDirty = true; + Col++; + } + } + else + { + Contents [Row, Col].Attribute = CurrentAttribute; + Contents [Row, Col].IsDirty = true; + + if (Col > 0) + { + // Check if cell to left has a wide glyph + if (Contents [Row, Col - 1].Rune.GetColumns () > 1) + { + // Invalidate cell to left + Contents [Row, Col - 1].Rune = Rune.ReplacementChar; + Contents [Row, Col - 1].IsDirty = true; + } + } + + if (runeWidth < 1) + { + Contents [Row, Col].Rune = Rune.ReplacementChar; + } + else if (runeWidth == 1) + { + Contents [Row, Col].Rune = rune; + + if (Col < clipRect.Right - 1) + { + Contents [Row, Col + 1].IsDirty = true; + } + } + else if (runeWidth == 2) + { + if (!Clip.Contains (Col + 1, Row)) + { + // We're at the right edge of the clip, so we can't display a wide character. + // TODO: Figure out if it is better to show a replacement character or ' ' + Contents [Row, Col].Rune = Rune.ReplacementChar; + } + else if (!Clip.Contains (Col, Row)) + { + // Our 1st column is outside the clip, so we can't display a wide character. + Contents [Row, Col + 1].Rune = Rune.ReplacementChar; + } + else + { + Contents [Row, Col].Rune = rune; + + if (Col < clipRect.Right - 1) + { + // Invalidate cell to right so that it doesn't get drawn + // TODO: Figure out if it is better to show a replacement character or ' ' + Contents [Row, Col + 1].Rune = Rune.ReplacementChar; + Contents [Row, Col + 1].IsDirty = true; + } + } + } + else + { + // This is a non-spacing character, so we don't need to do anything + Contents [Row, Col].Rune = (Rune)' '; + Contents [Row, Col].IsDirty = false; + } + + DirtyLines [Row] = true; + } + } + } + + if (runeWidth is < 0 or > 0) + { + Col++; + } + + if (runeWidth > 1) + { + Debug.Assert (runeWidth <= 2); + + if (validLocation && Col < clipRect.Right) + { + lock (Contents!) + { + // This is a double-width character, and we are not at the end of the line. + // Col now points to the second column of the character. Ensure it doesn't + // Get rendered. + Contents [Row, Col].IsDirty = false; + Contents [Row, Col].Attribute = CurrentAttribute; + + // TODO: Determine if we should wipe this out (for now now) + //Contents [Row, Col].Rune = (Rune)' '; + } + } + + Col++; + } + } + + /// <summary> + /// Adds the specified <see langword="char"/> to the display at the current cursor position. This method is a + /// convenience method that calls <see cref="AddRune(Rune)"/> with the <see cref="Rune"/> constructor. + /// </summary> + /// <param name="c">Character to add.</param> + public void AddRune (char c) { AddRune (new Rune (c)); } + + /// <summary>Adds the <paramref name="str"/> to the display at the cursor position.</summary> + /// <remarks> + /// <para> + /// When the method returns, <see cref="Col"/> will be incremented by the number of columns + /// <paramref name="str"/> required, unless the new column value is outside of the <see cref="Clip"/> or screen + /// dimensions defined by <see cref="Cols"/>. + /// </para> + /// <para>If <paramref name="str"/> requires more columns than are available, the output will be clipped.</para> + /// </remarks> + /// <param name="str">String.</param> + public void AddStr (string str) + { + List<Rune> runes = str.EnumerateRunes ().ToList (); + + for (var i = 0; i < runes.Count; i++) + { + AddRune (runes [i]); + } + } + + /// <summary>Clears the <see cref="Contents"/> of the driver.</summary> + public void ClearContents () + { + Contents = new Cell [Rows, Cols]; + + //CONCURRENCY: Unsynchronized access to Clip isn't safe. + // TODO: ClearContents should not clear the clip; it should only clear the contents. Move clearing it elsewhere. + Clip = new (Screen); + + DirtyLines = new bool [Rows]; + + lock (Contents) + { + for (var row = 0; row < Rows; row++) + { + for (var c = 0; c < Cols; c++) + { + Contents [row, c] = new () + { + Rune = (Rune)' ', + Attribute = new Attribute (Color.White, Color.Black), + IsDirty = true + }; + } + + DirtyLines [row] = true; + } + } + + // TODO: Who uses this and why? I am removing for now - this class is a state class not an events class + //ClearedContents?.Invoke (this, EventArgs.Empty); + } + + /// <summary>Tests whether the specified coordinate are valid for drawing the specified Rune.</summary> + /// <param name="rune">Used to determine if one or two columns are required.</param> + /// <param name="col">The column.</param> + /// <param name="row">The row.</param> + /// <returns> + /// <see langword="false"/> if the coordinate is outside the screen bounds or outside of <see cref="Clip"/>. + /// <see langword="true"/> otherwise. + /// </returns> + public bool IsValidLocation (Rune rune, int col, int row) + { + if (rune.GetColumns () < 2) + { + return col >= 0 && row >= 0 && col < Cols && row < Rows && Clip!.Contains (col, row); + } + + return Clip!.Contains (col, row) || Clip!.Contains (col + 1, row); + } + + /// <inheritdoc/> + public void SetWindowSize (int cols, int rows) + { + Cols = cols; + Rows = rows; + ClearContents (); + } + + /// <inheritdoc/> + public void FillRect (Rectangle rect, Rune rune) + { + // BUGBUG: This should be a method on Region + rect = Rectangle.Intersect (rect, Clip?.GetBounds () ?? Screen); + + lock (Contents!) + { + for (int r = rect.Y; r < rect.Y + rect.Height; r++) + { + for (int c = rect.X; c < rect.X + rect.Width; c++) + { + if (!IsValidLocation (rune, c, r)) + { + continue; + } + + Contents [r, c] = new () + { + Rune = rune != default (Rune) ? rune : (Rune)' ', + Attribute = CurrentAttribute, IsDirty = true + }; + } + } + } + } + + /// <inheritdoc/> + public void FillRect (Rectangle rect, char rune) + { + for (int y = rect.Top; y < rect.Top + rect.Height; y++) + { + for (int x = rect.Left; x < rect.Left + rect.Width; x++) + { + Move (x, y); + AddRune (rune); + } + } + } + + // TODO: Make internal once Menu is upgraded + /// <summary> + /// Updates <see cref="Col"/> and <see cref="Row"/> to the specified column and row in <see cref="Contents"/>. + /// Used by <see cref="AddRune(Rune)"/> and <see cref="AddStr"/> to determine where to add content. + /// </summary> + /// <remarks> + /// <para>This does not move the cursor on the screen, it only updates the internal state of the driver.</para> + /// <para> + /// If <paramref name="col"/> or <paramref name="row"/> are negative or beyond <see cref="Cols"/> and + /// <see cref="Rows"/>, the method still sets those properties. + /// </para> + /// </remarks> + /// <param name="col">Column to move to.</param> + /// <param name="row">Row to move to.</param> + public virtual void Move (int col, int row) + { + //Debug.Assert (col >= 0 && row >= 0 && col < Contents.GetLength(1) && row < Contents.GetLength(0)); + Col = col; + Row = row; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/ToplevelTransitionManager.cs b/Terminal.Gui/ConsoleDrivers/V2/ToplevelTransitionManager.cs new file mode 100644 index 0000000000..49bd86e4b6 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ToplevelTransitionManager.cs @@ -0,0 +1,37 @@ +#nullable enable +namespace Terminal.Gui; + +/// <summary> +/// Handles bespoke behaviours that occur when application top level changes. +/// </summary> +public class ToplevelTransitionManager : IToplevelTransitionManager +{ + private readonly HashSet<Toplevel> _readiedTopLevels = new (); + + private View? _lastTop; + + /// <inheritdoc/> + public void RaiseReadyEventIfNeeded () + { + Toplevel? top = Application.Top; + + if (top != null && !_readiedTopLevels.Contains (top)) + { + top.OnReady (); + _readiedTopLevels.Add (top); + } + } + + /// <inheritdoc/> + public void HandleTopMaybeChanging () + { + Toplevel? newTop = Application.Top; + + if (_lastTop != null && _lastTop != newTop && newTop != null) + { + newTop.SetNeedsDraw (); + } + + _lastTop = Application.Top; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/V2.cd b/Terminal.Gui/ConsoleDrivers/V2/V2.cd new file mode 100644 index 0000000000..f5004db3ce --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/V2.cd @@ -0,0 +1,569 @@ +<?xml version="1.0" encoding="utf-8"?> +<ClassDiagram MajorVersion="1" MinorVersion="1"> + <Comment CommentText="Thread 1 - Input thread, populates input buffer. This thread is hidden, nobody gets to interact directly with these classes)"> + <Position X="11" Y="0.5" Height="0.5" Width="5.325" /> + </Comment> + <Comment CommentText="Thread 2 - Main Loop which does everything else including output. Deals with input exclusively through the input buffer. Is accessible externally e.g. to Application"> + <Position X="11.083" Y="3.813" Height="0.479" Width="5.325" /> + </Comment> + <Comment CommentText="Orchestrates the 2 main threads in Terminal.Gui"> + <Position X="6.5" Y="1.25" Height="0.291" Width="2.929" /> + </Comment> + <Comment CommentText="Allows Views to work with new architecture without having to be rewritten."> + <Position X="4.666" Y="7.834" Height="0.75" Width="1.7" /> + </Comment> + <Comment CommentText="Ansi Escape Sequence - Request / Response"> + <Position X="19.208" Y="3.562" Height="0.396" Width="2.825" /> + </Comment> + <Comment CommentText="Mouse interpretation subsystem"> + <Position X="13.271" Y="9.896" Height="0.396" Width="2.075" /> + </Comment> + <Comment CommentText="In Terminal.Gui views get things done almost exclusively by calling static methods on Application e.g. RequestStop, Run, Refresh etc"> + <Position X="0.5" Y="3.75" Height="1.146" Width="1.7" /> + </Comment> + <Comment CommentText="Static record of system state and static gateway API for everything you ever need."> + <Position X="0.5" Y="1.417" Height="0.875" Width="1.7" /> + </Comment> + <Comment CommentText="Forwarded subset of gateway functionality. These exist to allow ''subclassing' Application. Note that most methods 'ping pong' a lot back to main gateway submethods e.g. to manipulate TopLevel etc"> + <Position X="2.895" Y="5.417" Height="1.063" Width="2.992" /> + </Comment> + <Class Name="Terminal.Gui.WindowsInput" Collapsed="true"> + <Position X="11.5" Y="3" Width="1.75" /> + <TypeIdentifier> + <HashCode>QIAACAAAACAEAAAAAAAAAAAkAAAAAAAAAwAAAAAAABA=</HashCode> + <FileName>ConsoleDrivers\V2\WindowsInput.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.NetInput" Collapsed="true"> + <Position X="13.25" Y="3" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAACAEAAAAQAAAAAAgAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\NetInput.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.ConsoleInput<T>" Collapsed="true"> + <Position X="12.5" Y="2" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAACAEAQAAAAAAAAAgACAAAAAAAAAAAAAAAAo=</HashCode> + <FileName>ConsoleDrivers\V2\ConsoleInput.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.MainLoop<T>" Collapsed="true" BaseTypeListCollapsed="true"> + <Position X="11" Y="4.75" Width="1.5" /> + <AssociationLine Name="TimedEvents" Type="Terminal.Gui.ITimedEvents" ManuallyRouted="true"> + <Path> + <Point X="11.312" Y="5.312" /> + <Point X="11.312" Y="6.292" /> + <Point X="10" Y="6.292" /> + <Point X="10" Y="7.25" /> + </Path> + <MemberNameLabel ManuallyPlaced="true"> + <Position X="-1.015" Y="1.019" /> + </MemberNameLabel> + </AssociationLine> + <AssociationLine Name="OutputBuffer" Type="Terminal.Gui.IOutputBuffer" ManuallyRouted="true"> + <Path> + <Point X="11.718" Y="5.312" /> + <Point X="11.718" Y="7.25" /> + </Path> + <MemberNameLabel ManuallyPlaced="true"> + <Position X="0.027" Y="0.102" /> + </MemberNameLabel> + </AssociationLine> + <AssociationLine Name="Out" Type="Terminal.Gui.IConsoleOutput" ManuallyRouted="true"> + <Path> + <Point X="12.5" Y="5.125" /> + <Point X="12.5" Y="5.792" /> + <Point X="13.031" Y="5.792" /> + <Point X="13.031" Y="7.846" /> + <Point X="14" Y="7.846" /> + </Path> + </AssociationLine> + <AssociationLine Name="AnsiRequestScheduler" Type="Terminal.Gui.AnsiRequestScheduler" ManuallyRouted="true"> + <Path> + <Point X="11.75" Y="4.75" /> + <Point X="11.75" Y="4.39" /> + <Point X="20.375" Y="4.39" /> + <Point X="20.375" Y="4.5" /> + </Path> + <MemberNameLabel ManuallyPlaced="true"> + <Position X="0.11" Y="0.143" /> + </MemberNameLabel> + </AssociationLine> + <AssociationLine Name="WindowSizeMonitor" Type="Terminal.Gui.IWindowSizeMonitor" ManuallyRouted="true"> + <Path> + <Point X="12.125" Y="5.312" /> + <Point X="12.125" Y="7" /> + <Point X="12.844" Y="7" /> + <Point X="12.844" Y="13.281" /> + <Point X="13.25" Y="13.281" /> + </Path> + <MemberNameLabel ManuallyPlaced="true"> + <Position X="0.047" Y="-0.336" /> + </MemberNameLabel> + </AssociationLine> + <AssociationLine Name="ToplevelTransitionManager" Type="Terminal.Gui.IToplevelTransitionManager" ManuallyRouted="true"> + <Path> + <Point X="11" Y="5.031" /> + <Point X="11" Y="5.406" /> + <Point X="9.021" Y="5.406" /> + <Point X="9.021" Y="11.5" /> + <Point X="10.375" Y="11.5" /> + <Point X="10.375" Y="12" /> + </Path> + <MemberNameLabel ManuallyPlaced="true"> + <Position X="-0.671" Y="0.529" /> + </MemberNameLabel> + </AssociationLine> + <TypeIdentifier> + <HashCode>QQQAAAAQACABJQQAABAAAQAAACAAAAACAAEAAACAEgg=</HashCode> + <FileName>ConsoleDrivers\V2\MainLoop.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Field Name="ToplevelTransitionManager" /> + <Property Name="TimedEvents" /> + <Property Name="InputProcessor" /> + <Property Name="OutputBuffer" /> + <Property Name="Out" /> + <Property Name="AnsiRequestScheduler" /> + <Property Name="WindowSizeMonitor" /> + </ShowAsAssociation> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.MainLoopCoordinator<T>"> + <Position X="6.5" Y="2" Width="2" /> + <TypeIdentifier> + <HashCode>IAAAIAEiCAIABAAAABQAAAAAABAAAQQAIQIABAAACgg=</HashCode> + <FileName>ConsoleDrivers\V2\MainLoopCoordinator.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Field Name="_loop" /> + </ShowAsAssociation> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.AnsiResponseParser<T>" Collapsed="true"> + <Position X="19.5" Y="10" Width="2" /> + <TypeIdentifier> + <HashCode>AAQAAAAAAAAACIAAAAAAAAAAAAAgAABAAAAACBAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.OutputBuffer"> + <Position X="11" Y="8.25" Width="1.5" /> + <Compartments> + <Compartment Name="Fields" Collapsed="true" /> + <Compartment Name="Methods" Collapsed="true" /> + </Compartments> + <TypeIdentifier> + <HashCode>AwAAAAAAAIAAAECIBgAEQIAAAAEMRgAACAAAKABAgAA=</HashCode> + <FileName>ConsoleDrivers\V2\OutputBuffer.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.NetOutput" Collapsed="true"> + <Position X="14.75" Y="8.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AEAAAAAAACAAAAAAAAAAAAAAAAAAQAAAMACAAAEAgAk=</HashCode> + <FileName>ConsoleDrivers\V2\NetOutput.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.WindowsOutput" Collapsed="true"> + <Position X="13.25" Y="8.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AEAAABACACAAhAAAAAAAACCAAAgAQAAIMAAAAAEAgAQ=</HashCode> + <FileName>ConsoleDrivers\V2\WindowsOutput.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.InputProcessor<T>" Collapsed="true"> + <Position X="16.5" Y="4.75" Width="2" /> + <AssociationLine Name="_mouseInterpreter" Type="Terminal.Gui.MouseInterpreter" ManuallyRouted="true"> + <Path> + <Point X="17.75" Y="5.312" /> + <Point X="17.75" Y="10.031" /> + <Point X="15.99" Y="10.031" /> + <Point X="15.99" Y="10.605" /> + <Point X="15" Y="10.605" /> + </Path> + </AssociationLine> + <TypeIdentifier> + <HashCode>AQAkEAAAAASAiAAEAgwgAAAABAIAAAAAAAAAAAAEAAA=</HashCode> + <FileName>ConsoleDrivers\V2\InputProcessor.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Field Name="_mouseInterpreter" /> + <Property Name="Parser" /> + <Property Name="KeyConverter" /> + </ShowAsAssociation> + <Lollipop Position="0.1" /> + </Class> + <Class Name="Terminal.Gui.NetInputProcessor" Collapsed="true"> + <Position X="17.75" Y="5.75" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAACBAAAgAAAEAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\NetInputProcessor.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.WindowsInputProcessor" Collapsed="true"> + <Position X="15.75" Y="5.75" Width="2" /> + <TypeIdentifier> + <HashCode>AQAAAAAAAAAACAAAAgAAAAAAAgAEAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\WindowsInputProcessor.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.AnsiMouseParser"> + <Position X="23.5" Y="9.75" Width="1.75" /> + <TypeIdentifier> + <HashCode>BAAAAAAAAAgAAAAAAAAAAAAAIAAAAAAAQAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiMouseParser.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.ConsoleDriverFacade<T>"> + <Position X="6.5" Y="7.75" Width="2" /> + <Compartments> + <Compartment Name="Methods" Collapsed="true" /> + <Compartment Name="Fields" Collapsed="true" /> + </Compartments> + <TypeIdentifier> + <HashCode>AQcgAAAAAKBAgFEIBBgAQJEAAjkaQiIAGQADKABDgAQ=</HashCode> + <FileName>ConsoleDrivers\V2\ConsoleDriverFacade.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Property Name="InputProcessor" /> + </ShowAsAssociation> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.AnsiRequestScheduler" Collapsed="true"> + <Position X="19.5" Y="4.5" Width="2" /> + <TypeIdentifier> + <HashCode>AAQAACAAIAAAIAACAESQAAQAACGAAAAAAAAAAAAAQQA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiRequestScheduler.cs</FileName> + </TypeIdentifier> + <ShowAsCollectionAssociation> + <Property Name="QueuedRequests" /> + </ShowAsCollectionAssociation> + </Class> + <Class Name="Terminal.Gui.AnsiResponseParserBase" Collapsed="true"> + <Position X="20.25" Y="9" Width="2" /> + <AssociationLine Name="_mouseParser" Type="Terminal.Gui.AnsiMouseParser" FixedFromPoint="true" FixedToPoint="true"> + <Path> + <Point X="22.25" Y="9.438" /> + <Point X="24.375" Y="9.438" /> + <Point X="24.375" Y="9.75" /> + </Path> + </AssociationLine> + <AssociationLine Name="_keyboardParser" Type="Terminal.Gui.AnsiKeyboardParser" FixedFromPoint="true"> + <Path> + <Point X="22.25" Y="9.375" /> + <Point X="25.5" Y="9.375" /> + </Path> + </AssociationLine> + <TypeIdentifier> + <HashCode>UAiASAAAEICQALAAQAAAKAAAoAIAAABAAQIAJiAQASQ=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Field Name="_mouseParser" /> + <Field Name="_keyboardParser" /> + <Field Name="_heldContent" /> + </ShowAsAssociation> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.MouseInterpreter"> + <Position X="13.25" Y="10.5" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAABAAAAAAAAAAAAgAAAAAAACAAAAAAAAUAAAAIAAA=</HashCode> + <FileName>ConsoleDrivers\V2\MouseInterpreter.cs</FileName> + </TypeIdentifier> + <ShowAsCollectionAssociation> + <Field Name="_buttonStates" /> + </ShowAsCollectionAssociation> + </Class> + <Class Name="Terminal.Gui.MouseButtonStateEx"> + <Position X="16.5" Y="10.25" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAMwAIAAAAAAAAAAAABCAAAAAAAAABAAEAAg=</HashCode> + <FileName>ConsoleDrivers\V2\MouseButtonStateEx.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.StringHeld" Collapsed="true"> + <Position X="21.5" Y="11" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAIAACAAAAAAAIBAAAAAAACAAAAAAAgAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\StringHeld.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.GenericHeld<T>" Collapsed="true"> + <Position X="19.75" Y="11" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAgAIAACAAAAAAAIBAAAAAAACAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\GenericHeld.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.AnsiEscapeSequenceRequest"> + <Position X="23" Y="4.5" Width="2.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAEAAAAAAAEAAAAACAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiEscapeSequenceRequest.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.AnsiEscapeSequence" Collapsed="true"> + <Position X="23" Y="3.75" Width="2.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAgAAEAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiEscapeSequence.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.AnsiResponseParser" Collapsed="true"> + <Position X="21.5" Y="10" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAgACBAAAAACBAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.Application" Collapsed="true"> + <Position X="0.5" Y="0.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>hEK4FAgAqARIspQeBwoUgTGgACNL0AIAESLKoggBSw8=</HashCode> + <FileName>Application\Application.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.ApplicationImpl" Collapsed="true"> + <Position X="2.75" Y="4.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AABAAAAAIAAIAgQQAAAAAQAAAAAAAAAAQAAKgAAAAAI=</HashCode> + <FileName>Application\ApplicationImpl.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Property Name="Instance" /> + </ShowAsAssociation> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.ApplicationV2" Collapsed="true"> + <Position X="4.75" Y="4.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>QAAAAAgABAEIBgAQAAAAAQBAAAAAgAEAAAAKgIAAAgI=</HashCode> + <FileName>ConsoleDrivers\V2\ApplicationV2.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Field Name="_coordinator" /> + </ShowAsAssociation> + </Class> + <Class Name="Terminal.Gui.View" Collapsed="true"> + <Position X="0.5" Y="3" Width="1.5" /> + <TypeIdentifier> + <HashCode>3/v2dzPLvbb/5+LOHuv1x0dem3Y57v/8c6afz2/e/Y8=</HashCode> + <FileName>View\View.Adornments.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.WindowsKeyConverter" Collapsed="true"> + <Position X="16" Y="7.5" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\WindowsKeyConverter.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.NetKeyConverter" Collapsed="true"> + <Position X="17.75" Y="7.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\NetKeyConverter.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.AnsiKeyboardParser"> + <Position X="25.5" Y="9.25" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAE=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\Keyboard\AnsiKeyboardParser.cs</FileName> + </TypeIdentifier> + <ShowAsCollectionAssociation> + <Field Name="_patterns" /> + </ShowAsCollectionAssociation> + </Class> + <Class Name="Terminal.Gui.ToplevelTransitionManager" Collapsed="true"> + <Position X="9.25" Y="13.75" Width="2.25" /> + <TypeIdentifier> + <HashCode>AIAAAAAAAAAAAAEAAAAAAAAAAEIAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\ToplevelTransitionManager.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.Logging" Collapsed="true"> + <Position X="0.5" Y="5.25" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAIgAAAAAAEQAAAAAAAAABAAgAAAAAAAEAA=</HashCode> + <FileName>ConsoleDrivers\V2\Logging.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.WindowSizeMonitor" Collapsed="true" BaseTypeListCollapsed="true"> + <Position X="13.25" Y="14" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAgAAAAAAAAAAEAAAAABAAAAAACAAAAAAAAAAAACA=</HashCode> + <FileName>ConsoleDrivers\V2\WindowSizeMonitor.cs</FileName> + </TypeIdentifier> + <Lollipop Position="0.2" /> + </Class> + <Class Name="Terminal.Gui.AnsiKeyboardParserPattern" Collapsed="true"> + <Position X="28.5" Y="9.5" Width="2" /> + <TypeIdentifier> + <HashCode>AAACIAAAAAAAAAAAAAAAAAQQAAAAAAAAAAAAAAAACAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\Keyboard\AnsiKeyboardParserPattern.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.CsiKeyPattern" Collapsed="true"> + <Position X="25.5" Y="10.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAACAAAAAAAAABAAAAAAAAAQAACAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\Keyboard\CsiKeyPattern.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.EscAsAltPattern" Collapsed="true"> + <Position X="27.75" Y="10.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAACAAAAAAAAAAAAAAAAAAAQAACAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\Keyboard\EscAsAltPattern.cs</FileName> + </TypeIdentifier> + </Class> + <Class Name="Terminal.Gui.Ss3Pattern" Collapsed="true"> + <Position X="29.5" Y="10.75" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAACAAAAAAAAAAAAAAAAAAAQAACAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\Keyboard\Ss3Pattern.cs</FileName> + </TypeIdentifier> + </Class> + <Interface Name="Terminal.Gui.IConsoleInput<T>" Collapsed="true"> + <Position X="12.5" Y="1" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAI=</HashCode> + <FileName>ConsoleDrivers\V2\IConsoleInput.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IMainLoop<T>" Collapsed="true"> + <Position X="9.25" Y="4.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>QAQAAAAAAAABIQQAAAAAAAAAAAAAAAACAAAAAAAAEAA=</HashCode> + <FileName>ConsoleDrivers\V2\IMainLoop.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IConsoleOutput" Collapsed="true"> + <Position X="14" Y="7.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAMAAAAAEAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IConsoleOutput.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IOutputBuffer" Collapsed="true"> + <Position X="11" Y="7.25" Width="1.5" /> + <TypeIdentifier> + <HashCode>AQAAAAAAAIAAAEAIAAAAQIAAAAEMRgAACAAAKABAgAA=</HashCode> + <FileName>ConsoleDrivers\V2\IOutputBuffer.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IInputProcessor"> + <Position X="14" Y="4.5" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAkAAAAAACAgAAAAAggAAAABAIAAAAAAAAAAAAEAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IInputProcessor.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IHeld"> + <Position X="23.75" Y="6.5" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAIAACAAAAAAAIBAAAAAAACAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\IHeld.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IAnsiResponseParser"> + <Position X="20.25" Y="5.25" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAQAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAJAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\IAnsiResponseParser.cs</FileName> + </TypeIdentifier> + <ShowAsAssociation> + <Property Name="State" /> + </ShowAsAssociation> + </Interface> + <Interface Name="Terminal.Gui.IApplication"> + <Position X="3" Y="1" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAIAgQQAAAAAQAAAAAAAAAAAAAKgAAAAAI=</HashCode> + <FileName>Application\IApplication.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IMainLoopCoordinator" Collapsed="true"> + <Position X="6.5" Y="0.5" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQIAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IMainLoopCoordinator.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IWindowSizeMonitor" Collapsed="true"> + <Position X="13.25" Y="13" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAEAAAAAAAAAAAACAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IWindowSizeMonitor.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.ITimedEvents"> + <Position X="9.25" Y="7.25" Width="1.5" /> + <Compartments> + <Compartment Name="Methods" Collapsed="true" /> + </Compartments> + <TypeIdentifier> + <HashCode>BAAAIAAAAQAAAAAQACAAAIBAAQAAAAAAAAAIgAAAAAA=</HashCode> + <FileName>Application\ITimedEvents.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IKeyConverter<T>" Collapsed="true"> + <Position X="17" Y="6.5" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IKeyConverter.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IToplevelTransitionManager"> + <Position X="9.25" Y="12" Width="2.25" /> + <TypeIdentifier> + <HashCode>AIAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IToplevelTransitionManager.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IConsoleDriverFacade"> + <Position X="4.5" Y="8.75" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IConsoleDriverFacade.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.INetInput" Collapsed="true"> + <Position X="14.25" Y="2" Width="1.75" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\INetInput.cs</FileName> + </TypeIdentifier> + </Interface> + <Interface Name="Terminal.Gui.IWindowsInput" Collapsed="true"> + <Position X="10.75" Y="2" Width="1.5" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\V2\IWindowsInput.cs</FileName> + </TypeIdentifier> + </Interface> + <Enum Name="Terminal.Gui.AnsiResponseParserState"> + <Position X="20.25" Y="7.25" Width="2" /> + <TypeIdentifier> + <HashCode>AAAAAAAAAAAAAAAAAAAACAAAAAAIAAIAAAAAAAAAAAA=</HashCode> + <FileName>ConsoleDrivers\AnsiResponseParser\AnsiResponseParserState.cs</FileName> + </TypeIdentifier> + </Enum> + <Font Name="Segoe UI" Size="9" /> +</ClassDiagram> \ No newline at end of file diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowSizeMonitor.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowSizeMonitor.cs new file mode 100644 index 0000000000..18707fa83a --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowSizeMonitor.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +internal class WindowSizeMonitor : IWindowSizeMonitor +{ + private readonly IConsoleOutput _consoleOut; + private readonly IOutputBuffer _outputBuffer; + private Size _lastSize = new (0, 0); + + /// <summary>Invoked when the terminal's size changed. The new size of the terminal is provided.</summary> + public event EventHandler<SizeChangedEventArgs> SizeChanging; + + public WindowSizeMonitor (IConsoleOutput consoleOut, IOutputBuffer outputBuffer) + { + _consoleOut = consoleOut; + _outputBuffer = outputBuffer; + } + + /// <inheritdoc/> + public bool Poll () + { + Size size = _consoleOut.GetWindowSize (); + + if (size != _lastSize) + { + Logging.Logger.LogInformation ($"Console size changes from '{_lastSize}' to {size}"); + _outputBuffer.SetWindowSize (size.Width, size.Height); + _lastSize = size; + SizeChanging?.Invoke (this, new (size)); + + return true; + } + + return false; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsInput.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsInput.cs new file mode 100644 index 0000000000..423c6f06a0 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsInput.cs @@ -0,0 +1,114 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using static Terminal.Gui.WindowsConsole; + +namespace Terminal.Gui; + +internal class WindowsInput : ConsoleInput<InputRecord>, IWindowsInput +{ + private readonly nint _inputHandle; + + [DllImport ("kernel32.dll", EntryPoint = "ReadConsoleInputW", CharSet = CharSet.Unicode)] + public static extern bool ReadConsoleInput ( + nint hConsoleInput, + nint lpBuffer, + uint nLength, + out uint lpNumberOfEventsRead + ); + + [DllImport ("kernel32.dll", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)] + public static extern bool PeekConsoleInput ( + nint hConsoleInput, + nint lpBuffer, + uint nLength, + out uint lpNumberOfEventsRead + ); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle (int nStdHandle); + + [DllImport ("kernel32.dll")] + private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); + + [DllImport ("kernel32.dll")] + private static extern bool SetConsoleMode (nint hConsoleHandle, uint dwMode); + + private readonly uint _originalConsoleMode; + + public WindowsInput () + { + Logging.Logger.LogInformation ($"Creating {nameof (WindowsInput)}"); + _inputHandle = GetStdHandle (STD_INPUT_HANDLE); + + GetConsoleMode (_inputHandle, out uint v); + _originalConsoleMode = v; + + uint newConsoleMode = _originalConsoleMode; + newConsoleMode |= (uint)(ConsoleModes.EnableMouseInput | ConsoleModes.EnableExtendedFlags); + newConsoleMode &= ~(uint)ConsoleModes.EnableQuickEditMode; + newConsoleMode &= ~(uint)ConsoleModes.EnableProcessedInput; + SetConsoleMode (_inputHandle, newConsoleMode); + } + + protected override bool Peek () + { + const int bufferSize = 1; // We only need to check if there's at least one event + nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<InputRecord> () * bufferSize); + + try + { + // Use PeekConsoleInput to inspect the input buffer without removing events + if (PeekConsoleInput (_inputHandle, pRecord, bufferSize, out uint numberOfEventsRead)) + { + // Return true if there's at least one event in the buffer + return numberOfEventsRead > 0; + } + else + { + // Handle the failure of PeekConsoleInput + throw new InvalidOperationException ("Failed to peek console input."); + } + } + catch (Exception ex) + { + // Optionally log the exception + Console.WriteLine ($"Error in Peek: {ex.Message}"); + + return false; + } + finally + { + // Free the allocated memory + Marshal.FreeHGlobal (pRecord); + } + } + + protected override IEnumerable<InputRecord> Read () + { + const int bufferSize = 1; + nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf<InputRecord> () * bufferSize); + + try + { + ReadConsoleInput ( + _inputHandle, + pRecord, + bufferSize, + out uint numberEventsRead); + + return numberEventsRead == 0 + ? [] + : new [] { Marshal.PtrToStructure<InputRecord> (pRecord) }; + } + catch (Exception) + { + return []; + } + finally + { + Marshal.FreeHGlobal (pRecord); + } + } + + public override void Dispose () { SetConsoleMode (_inputHandle, _originalConsoleMode); } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsInputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsInputProcessor.cs new file mode 100644 index 0000000000..da1e81887a --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsInputProcessor.cs @@ -0,0 +1,157 @@ +#nullable enable +using System.Collections.Concurrent; +using static Terminal.Gui.WindowsConsole; + +namespace Terminal.Gui; + +using InputRecord = InputRecord; + +/// <summary> +/// Input processor for <see cref="WindowsInput"/>, deals in <see cref="WindowsConsole.InputRecord"/> stream. +/// </summary> +internal class WindowsInputProcessor : InputProcessor<InputRecord> +{ + private readonly bool [] _lastWasPressed = new bool[4]; + + /// <inheritdoc/> + public WindowsInputProcessor (ConcurrentQueue<InputRecord> inputBuffer) : base (inputBuffer, new WindowsKeyConverter ()) { } + + /// <inheritdoc/> + protected override void Process (InputRecord inputEvent) + { + switch (inputEvent.EventType) + { + case EventType.Key: + + // TODO: For now ignore keyup because ANSI comes in as down+up which is confusing to try and parse/pair these things up + if (!inputEvent.KeyEvent.bKeyDown) + { + return; + } + + foreach (Tuple<char, InputRecord> released in Parser.ProcessInput (Tuple.Create (inputEvent.KeyEvent.UnicodeChar, inputEvent))) + { + ProcessAfterParsing (released.Item2); + } + + /* + if (inputEvent.KeyEvent.wVirtualKeyCode == (VK)ConsoleKey.Packet) + { + // Used to pass Unicode characters as if they were keystrokes. + // The VK_PACKET key is the low word of a 32-bit + // Virtual Key value used for non-keyboard input methods. + inputEvent.KeyEvent = FromVKPacketToKeyEventRecord (inputEvent.KeyEvent); + } + + WindowsConsole.ConsoleKeyInfoEx keyInfo = ToConsoleKeyInfoEx (inputEvent.KeyEvent); + + //Debug.WriteLine ($"event: KBD: {GetKeyboardLayoutName()} {inputEvent.ToString ()} {keyInfo.ToString (keyInfo)}"); + + KeyCode map = MapKey (keyInfo); + + if (map == KeyCode.Null) + { + break; + } + */ + // This follows convention in NetDriver + + break; + + case EventType.Mouse: + MouseEventArgs me = ToDriverMouse (inputEvent.MouseEvent); + + OnMouseEvent (me); + + break; + } + } + + /// <inheritdoc/> + protected override void ProcessAfterParsing (InputRecord input) + { + var key = KeyConverter.ToKey (input); + + if (key != (Key)0) + { + OnKeyDown (key!); + OnKeyUp (key!); + } + } + + public MouseEventArgs ToDriverMouse (MouseEventRecord e) + { + var mouseFlags = MouseFlags.ReportMousePosition; + + mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, ButtonState.Button1Pressed, MouseFlags.Button1Pressed, MouseFlags.Button1Released, 0); + mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, ButtonState.Button2Pressed, MouseFlags.Button2Pressed, MouseFlags.Button2Released, 1); + mouseFlags = UpdateMouseFlags (mouseFlags, e.ButtonState, ButtonState.Button4Pressed, MouseFlags.Button4Pressed, MouseFlags.Button4Released, 3); + + // Deal with button 3 separately because it is considered same as 'rightmost button' + if (e.ButtonState.HasFlag (ButtonState.Button3Pressed) || e.ButtonState.HasFlag (ButtonState.RightmostButtonPressed)) + { + mouseFlags |= MouseFlags.Button3Pressed; + _lastWasPressed [2] = true; + } + else + { + if (_lastWasPressed [2]) + { + mouseFlags |= MouseFlags.Button3Released; + _lastWasPressed [2] = false; + } + } + + if (e.EventFlags == EventFlags.MouseWheeled) + { + switch ((int)e.ButtonState) + { + case > 0: + mouseFlags = MouseFlags.WheeledUp; + + break; + + case < 0: + mouseFlags = MouseFlags.WheeledDown; + + break; + } + } + + var result = new MouseEventArgs + { + Position = new (e.MousePosition.X, e.MousePosition.Y), + Flags = mouseFlags + }; + + // TODO: Return keys too + + return result; + } + + private MouseFlags UpdateMouseFlags ( + MouseFlags current, + ButtonState newState, + ButtonState pressedState, + MouseFlags pressedFlag, + MouseFlags releasedFlag, + int buttonIndex + ) + { + if (newState.HasFlag (pressedState)) + { + current |= pressedFlag; + _lastWasPressed [buttonIndex] = true; + } + else + { + if (_lastWasPressed [buttonIndex]) + { + current |= releasedFlag; + _lastWasPressed [buttonIndex] = false; + } + } + + return current; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs new file mode 100644 index 0000000000..fd092db67c --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs @@ -0,0 +1,38 @@ +#nullable enable +using Terminal.Gui.ConsoleDrivers; + +namespace Terminal.Gui; + +/// <summary> +/// <see cref="IKeyConverter{T}"/> capable of converting the +/// windows native <see cref="WindowsConsole.InputRecord"/> class +/// into Terminal.Gui shared <see cref="Key"/> representation +/// (used by <see cref="View"/> etc). +/// </summary> +internal class WindowsKeyConverter : IKeyConverter<WindowsConsole.InputRecord> +{ + /// <inheritdoc/> + public Key ToKey (WindowsConsole.InputRecord inputEvent) + { + if (inputEvent.KeyEvent.wVirtualKeyCode == (ConsoleKeyMapping.VK)ConsoleKey.Packet) + { + // Used to pass Unicode characters as if they were keystrokes. + // The VK_PACKET key is the low word of a 32-bit + // Virtual Key value used for non-keyboard input methods. + inputEvent.KeyEvent = WindowsDriver.FromVKPacketToKeyEventRecord (inputEvent.KeyEvent); + } + + var keyInfo = WindowsDriver.ToConsoleKeyInfoEx (inputEvent.KeyEvent); + + //Debug.WriteLine ($"event: KBD: {GetKeyboardLayoutName()} {inputEvent.ToString ()} {keyInfo.ToString (keyInfo)}"); + + KeyCode map = WindowsDriver.MapKey (keyInfo); + + if (map == KeyCode.Null) + { + return (Key)0; + } + + return new (map); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsOutput.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsOutput.cs new file mode 100644 index 0000000000..382b01aa87 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsOutput.cs @@ -0,0 +1,344 @@ +#nullable enable +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using static Terminal.Gui.WindowsConsole; + +namespace Terminal.Gui; + +internal class WindowsOutput : IConsoleOutput +{ + [DllImport ("kernel32.dll", EntryPoint = "WriteConsole", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool WriteConsole ( + nint hConsoleOutput, + string lpbufer, + uint numberOfCharsToWriten, + out uint lpNumberOfCharsWritten, + nint lpReserved + ); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle (nint handle); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern nint CreateConsoleScreenBuffer ( + DesiredAccess dwDesiredAccess, + ShareMode dwShareMode, + nint secutiryAttributes, + uint flags, + nint screenBufferData + ); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern bool GetConsoleScreenBufferInfoEx (nint hConsoleOutput, ref CONSOLE_SCREEN_BUFFER_INFOEX csbi); + + [Flags] + private enum ShareMode : uint + { + FileShareRead = 1, + FileShareWrite = 2 + } + + [Flags] + private enum DesiredAccess : uint + { + GenericRead = 2147483648, + GenericWrite = 1073741824 + } + + internal static nint INVALID_HANDLE_VALUE = new (-1); + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern bool SetConsoleActiveScreenBuffer (nint handle); + + [DllImport ("kernel32.dll")] + private static extern bool SetConsoleCursorPosition (nint hConsoleOutput, Coord dwCursorPosition); + + private readonly nint _screenBuffer; + + public WindowsOutput () + { + Logging.Logger.LogInformation ($"Creating {nameof (WindowsOutput)}"); + + _screenBuffer = CreateConsoleScreenBuffer ( + DesiredAccess.GenericRead | DesiredAccess.GenericWrite, + ShareMode.FileShareRead | ShareMode.FileShareWrite, + nint.Zero, + 1, + nint.Zero + ); + + if (_screenBuffer == INVALID_HANDLE_VALUE) + { + int err = Marshal.GetLastWin32Error (); + + if (err != 0) + { + throw new Win32Exception (err); + } + } + + if (!SetConsoleActiveScreenBuffer (_screenBuffer)) + { + throw new Win32Exception (Marshal.GetLastWin32Error ()); + } + } + + public void Write (string str) + { + if (!WriteConsole (_screenBuffer, str, (uint)str.Length, out uint _, nint.Zero)) + { + throw new Win32Exception (Marshal.GetLastWin32Error (), "Failed to write to console screen buffer."); + } + } + + public void Write (IOutputBuffer buffer) + { + ExtendedCharInfo [] outputBuffer = new ExtendedCharInfo [buffer.Rows * buffer.Cols]; + + // TODO: probably do need this right? + /* + if (!windowSize.IsEmpty && (windowSize.Width != buffer.Cols || windowSize.Height != buffer.Rows)) + { + return; + }*/ + + var bufferCoords = new Coord + { + X = (short)buffer.Cols, //Clip.Width, + Y = (short)buffer.Rows //Clip.Height + }; + + for (var row = 0; row < buffer.Rows; row++) + { + if (!buffer.DirtyLines [row]) + { + continue; + } + + buffer.DirtyLines [row] = false; + + for (var col = 0; col < buffer.Cols; col++) + { + int position = row * buffer.Cols + col; + outputBuffer [position].Attribute = buffer.Contents [row, col].Attribute.GetValueOrDefault (); + + if (buffer.Contents [row, col].IsDirty == false) + { + outputBuffer [position].Empty = true; + outputBuffer [position].Char = (char)Rune.ReplacementChar.Value; + + continue; + } + + outputBuffer [position].Empty = false; + + if (buffer.Contents [row, col].Rune.IsBmp) + { + outputBuffer [position].Char = (char)buffer.Contents [row, col].Rune.Value; + } + else + { + //outputBuffer [position].Empty = true; + outputBuffer [position].Char = (char)Rune.ReplacementChar.Value; + + if (buffer.Contents [row, col].Rune.GetColumns () > 1 && col + 1 < buffer.Cols) + { + // TODO: This is a hack to deal with non-BMP and wide characters. + col++; + position = row * buffer.Cols + col; + outputBuffer [position].Empty = false; + outputBuffer [position].Char = ' '; + } + } + } + } + + var damageRegion = new SmallRect + { + Top = 0, + Left = 0, + Bottom = (short)buffer.Rows, + Right = (short)buffer.Cols + }; + + //size, ExtendedCharInfo [] charInfoBuffer, Coord , SmallRect window, + if (!WriteToConsole ( + new (buffer.Cols, buffer.Rows), + outputBuffer, + bufferCoords, + damageRegion, + false)) + { + int err = Marshal.GetLastWin32Error (); + + if (err != 0) + { + throw new Win32Exception (err); + } + } + + SmallRect.MakeEmpty (ref damageRegion); + } + + public bool WriteToConsole (Size size, ExtendedCharInfo [] charInfoBuffer, Coord bufferSize, SmallRect window, bool force16Colors) + { + var stringBuilder = new StringBuilder (); + + //Debug.WriteLine ("WriteToConsole"); + + //if (_screenBuffer == nint.Zero) + //{ + // ReadFromConsoleOutput (size, bufferSize, ref window); + //} + + var result = false; + + if (force16Colors) + { + var i = 0; + CharInfo [] ci = new CharInfo [charInfoBuffer.Length]; + + foreach (ExtendedCharInfo info in charInfoBuffer) + { + ci [i++] = new () + { + Char = new () { UnicodeChar = info.Char }, + Attributes = + (ushort)((int)info.Attribute.Foreground.GetClosestNamedColor16 () | ((int)info.Attribute.Background.GetClosestNamedColor16 () << 4)) + }; + } + + result = WriteConsoleOutput (_screenBuffer, ci, bufferSize, new () { X = window.Left, Y = window.Top }, ref window); + } + else + { + stringBuilder.Clear (); + + stringBuilder.Append (EscSeqUtils.CSI_SaveCursorPosition); + stringBuilder.Append (EscSeqUtils.CSI_SetCursorPosition (0, 0)); + + Attribute? prev = null; + + foreach (ExtendedCharInfo info in charInfoBuffer) + { + Attribute attr = info.Attribute; + + if (attr != prev) + { + prev = attr; + stringBuilder.Append (EscSeqUtils.CSI_SetForegroundColorRGB (attr.Foreground.R, attr.Foreground.G, attr.Foreground.B)); + stringBuilder.Append (EscSeqUtils.CSI_SetBackgroundColorRGB (attr.Background.R, attr.Background.G, attr.Background.B)); + } + + if (info.Char != '\x1b') + { + if (!info.Empty) + { + stringBuilder.Append (info.Char); + } + } + else + { + stringBuilder.Append (' '); + } + } + + stringBuilder.Append (EscSeqUtils.CSI_RestoreCursorPosition); + stringBuilder.Append (EscSeqUtils.CSI_HideCursor); + + var s = stringBuilder.ToString (); + + // TODO: requires extensive testing if we go down this route + // If console output has changed + //if (s != _lastWrite) + //{ + // supply console with the new content + result = WriteConsole (_screenBuffer, s, (uint)s.Length, out uint _, nint.Zero); + + foreach (SixelToRender sixel in Application.Sixel) + { + SetCursorPosition ((short)sixel.ScreenPosition.X, (short)sixel.ScreenPosition.Y); + WriteConsole (_screenBuffer, sixel.SixelData, (uint)sixel.SixelData.Length, out uint _, nint.Zero); + } + } + + if (!result) + { + int err = Marshal.GetLastWin32Error (); + + if (err != 0) + { + throw new Win32Exception (err); + } + } + + return result; + } + + public Size GetWindowSize () + { + var csbi = new CONSOLE_SCREEN_BUFFER_INFOEX (); + csbi.cbSize = (uint)Marshal.SizeOf (csbi); + + if (!GetConsoleScreenBufferInfoEx (_screenBuffer, ref csbi)) + { + //throw new System.ComponentModel.Win32Exception (Marshal.GetLastWin32Error ()); + return Size.Empty; + } + + Size sz = new ( + csbi.srWindow.Right - csbi.srWindow.Left + 1, + csbi.srWindow.Bottom - csbi.srWindow.Top + 1); + + return sz; + } + + /// <inheritdoc/> + public void SetCursorVisibility (CursorVisibility visibility) + { + var sb = new StringBuilder (); + sb.Append (visibility != CursorVisibility.Invisible ? EscSeqUtils.CSI_ShowCursor : EscSeqUtils.CSI_HideCursor); + Write (sb.ToString ()); + } + + private Point _lastCursorPosition; + + /// <inheritdoc/> + public void SetCursorPosition (int col, int row) + { + if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row) + { + return; + } + + _lastCursorPosition = new (col, row); + + SetConsoleCursorPosition (_screenBuffer, new ((short)col, (short)row)); + } + + private bool _isDisposed; + + /// <inheritdoc/> + public void Dispose () + { + if (_isDisposed) + { + return; + } + + if (_screenBuffer != nint.Zero) + { + try + { + CloseHandle (_screenBuffer); + } + catch (Exception e) + { + Logging.Logger.LogError (e, "Error trying to close screen buffer handle in WindowsOutput via interop method"); + } + } + + _isDisposed = true; + } +} diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsConsole.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsConsole.cs index 0d9a562543..8266790511 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsConsole.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsConsole.cs @@ -36,7 +36,7 @@ public WindowsConsole () newConsoleMode &= ~(uint)ConsoleModes.EnableProcessedInput; ConsoleMode = newConsoleMode; - _inputReadyCancellationTokenSource = new (); + _inputReadyCancellationTokenSource = new (); Task.Run (ProcessInputQueue, _inputReadyCancellationTokenSource.Token); } @@ -918,7 +918,7 @@ ref SmallRect lpReadRegion // TODO: This API is obsolete. See https://learn.microsoft.com/en-us/windows/console/writeconsoleoutput [DllImport ("kernel32.dll", EntryPoint = "WriteConsoleOutputW", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool WriteConsoleOutput ( + public static extern bool WriteConsoleOutput ( nint hConsoleOutput, CharInfo [] lpBuffer, Coord dwBufferSize, diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs index db680d5c73..26ca3023b4 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsDriver.cs @@ -70,7 +70,7 @@ public WindowsDriver () public WindowsConsole? WinConsole { get; private set; } - public WindowsConsole.KeyEventRecord FromVKPacketToKeyEventRecord (WindowsConsole.KeyEventRecord keyEvent) + public static WindowsConsole.KeyEventRecord FromVKPacketToKeyEventRecord (WindowsConsole.KeyEventRecord keyEvent) { if (keyEvent.wVirtualKeyCode != (VK)ConsoleKey.Packet) { @@ -203,7 +203,7 @@ public override void WriteRaw (string str) #endregion - public WindowsConsole.ConsoleKeyInfoEx ToConsoleKeyInfoEx (WindowsConsole.KeyEventRecord keyEvent) + public static WindowsConsole.ConsoleKeyInfoEx ToConsoleKeyInfoEx (WindowsConsole.KeyEventRecord keyEvent) { WindowsConsole.ControlKeyState state = keyEvent.dwControlKeyState; @@ -582,7 +582,7 @@ internal void ProcessInputAfterParsing (WindowsConsole.InputRecord inputEvent) public IEnumerable<WindowsConsole.InputRecord> ShouldReleaseParserHeldKeys () { - if (_parser.State == AnsiResponseParserState.ExpectingBracket && + if (_parser.State == AnsiResponseParserState.ExpectingEscapeSequence && DateTime.Now - _parser.StateChangedAt > EscTimeout) { return _parser.Release ().Select (o => o.Item2); @@ -627,7 +627,7 @@ private void ChangeWin (object s, SizeChangedEventArgs e) } #endif - private KeyCode MapKey (WindowsConsole.ConsoleKeyInfoEx keyInfoEx) + public static KeyCode MapKey (WindowsConsole.ConsoleKeyInfoEx keyInfoEx) { ConsoleKeyInfo keyInfo = keyInfoEx.ConsoleKeyInfo; diff --git a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsMainLoop.cs b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsMainLoop.cs index fedcf6f732..13fcafbd9a 100644 --- a/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsMainLoop.cs +++ b/Terminal.Gui/ConsoleDrivers/WindowsDriver/WindowsMainLoop.cs @@ -68,7 +68,7 @@ bool IMainLoopDriver.EventsPending () #if HACK_CHECK_WINCHANGED _winChange.Set (); #endif - if (_resultQueue.Count > 0 || _mainLoop!.CheckTimersAndIdleHandlers (out int waitTimeout)) + if (_resultQueue.Count > 0 || _mainLoop!.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeout)) { return true; } @@ -97,9 +97,9 @@ bool IMainLoopDriver.EventsPending () if (!_eventReadyTokenSource.IsCancellationRequested) { #if HACK_CHECK_WINCHANGED - return _resultQueue.Count > 0 || _mainLoop.CheckTimersAndIdleHandlers (out _) || _winChanged; + return _resultQueue.Count > 0 || _mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out _) || _winChanged; #else - return _resultQueue.Count > 0 || _mainLoop.CheckTimersAndIdleHandlers (out _); + return _resultQueue.Count > 0 || _mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out _); #endif } diff --git a/Terminal.Gui/Input/Keyboard/Key.cs b/Terminal.Gui/Input/Keyboard/Key.cs index cf524b6e5b..53d6292756 100644 --- a/Terminal.Gui/Input/Keyboard/Key.cs +++ b/Terminal.Gui/Input/Keyboard/Key.cs @@ -418,7 +418,7 @@ public override bool Equals (object? obj) /// <param name="a"></param> /// <param name="b"></param> /// <returns></returns> - public static bool operator != (Key a, Key b) { return !a!.Equals (b); } + public static bool operator != (Key a, Key? b) { return !a!.Equals (b); } /// <summary>Compares two <see cref="Key"/>s for less-than.</summary> /// <param name="a"></param> diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index bd2d56f292..aaadfee756 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -58,6 +58,7 @@ <PackageReference Include="Microsoft.CodeAnalysis" Version="[4.10,5)" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.Common" Version="[4.10,5)" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="[4.10,5)" PrivateAssets="all" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <!-- Enable Nuget Source Link for github --> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="[8,9)" PrivateAssets="all" /> <PackageReference Include="System.IO.Abstractions" Version="[21.0.22,22)" /> @@ -80,6 +81,7 @@ <ItemGroup> <InternalsVisibleTo Include="UnitTests" /> <InternalsVisibleTo Include="TerminalGuiDesigner" /> + <InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> </ItemGroup> <!-- =================================================================== --> <!-- API Documentation --> diff --git a/Terminal.Gui/Text/CollectionNavigatorBase.cs b/Terminal.Gui/Text/CollectionNavigatorBase.cs index 4caa219cd9..3ffe411d54 100644 --- a/Terminal.Gui/Text/CollectionNavigatorBase.cs +++ b/Terminal.Gui/Text/CollectionNavigatorBase.cs @@ -1,4 +1,6 @@ -namespace Terminal.Gui; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; /// <summary> /// Navigates a collection of items using keystrokes. The keystrokes are used to build a search string. The @@ -57,18 +59,23 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // but if we find none then we must fallback on cycling // d instead and discard the candidate state var candidateState = ""; + var elapsedTime = DateTime.Now - _lastKeystroke; + + Logging.Trace($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke"); // is it a second or third (etc) keystroke within a short time - if (SearchString.Length > 0 && DateTime.Now - _lastKeystroke < TimeSpan.FromMilliseconds (TypingDelay)) + if (SearchString.Length > 0 && elapsedTime < TimeSpan.FromMilliseconds (TypingDelay)) { // "dd" is a candidate candidateState = SearchString + keyStruck; + Logging.Trace($"Appending, search is now for '{candidateState}'"); } else { // its a fresh keystroke after some time // or its first ever key press SearchString = new string (keyStruck, 1); + Logging.Trace($"It has been too long since last key press so beginning new search"); } int idxCandidate = GetNextMatchingItem ( @@ -79,12 +86,14 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) candidateState.Length > 1 ); + Logging.Trace($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}"); if (idxCandidate != -1) { // found "dd" so candidate search string is accepted _lastKeystroke = DateTime.Now; SearchString = candidateState; + Logging.Trace($"Found collection item that matched search:{idxCandidate}"); return idxCandidate; } @@ -93,10 +102,13 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) _lastKeystroke = DateTime.Now; idxCandidate = GetNextMatchingItem (currentIndex, candidateState); + Logging.Trace($"CollectionNavigator searching (any match) matched:{idxCandidate}"); + // if a match wasn't found, the user typed a 'wrong' key in their search ("can" + 'z' // instead of "can" + 'd'). if (SearchString.Length > 1 && idxCandidate == -1) { + Logging.Trace("CollectionNavigator ignored key and returned existing index"); // ignore it since we're still within the typing delay // don't add it to SearchString either return currentIndex; @@ -105,6 +117,8 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // if no changes to current state manifested if (idxCandidate == currentIndex || idxCandidate == -1) { + Logging.Trace("CollectionNavigator found no changes to current index, so clearing search"); + // clear history and treat as a fresh letter ClearSearchString (); @@ -112,13 +126,18 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) SearchString = new string (keyStruck, 1); idxCandidate = GetNextMatchingItem (currentIndex, SearchString); + Logging.Trace($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}" ); + return idxCandidate == -1 ? currentIndex : idxCandidate; } + Logging.Trace($"CollectionNavigator final answer was:{idxCandidate}" ); // Found another "d" or just leave index as it was return idxCandidate; } + Logging.Trace("CollectionNavigator found key press was not actionable so clearing search and returning -1"); + // clear state because keypress was a control char ClearSearchString (); diff --git a/Terminal.Gui/View/View.Drawing.cs b/Terminal.Gui/View/View.Drawing.cs index e15339bb45..2e33706e14 100644 --- a/Terminal.Gui/View/View.Drawing.cs +++ b/Terminal.Gui/View/View.Drawing.cs @@ -738,7 +738,8 @@ public void SetNeedsDraw (Rectangle viewPortRelativeRegion) adornment.Parent?.SetSubViewNeedsDraw (); } - foreach (View subview in Subviews) + // There was multiple enumeration error here, so calling ToArray - probably a stop gap + foreach (View subview in Subviews.ToArray ()) { if (subview.Frame.IntersectsWith (viewPortRelativeRegion)) { diff --git a/Terminal.Gui/View/View.cs b/Terminal.Gui/View/View.cs index f0bf0daa04..8ffff38c86 100644 --- a/Terminal.Gui/View/View.cs +++ b/Terminal.Gui/View/View.cs @@ -163,8 +163,11 @@ public virtual void EndInit () } } - // TODO: Figure out how to move this out of here and just depend on LayoutNeeded in Mainloop - Layout (); // the EventLog in AllViewsTester fails to layout correctly if this is not here (convoluted Dim.Fill(Func)). + if (ApplicationImpl.Instance.IsLegacy) + { + // TODO: Figure out how to move this out of here and just depend on LayoutNeeded in Mainloop + Layout (); // the EventLog in AllViewsTester fails to layout correctly if this is not here (convoluted Dim.Fill(Func)). + } SetNeedsLayout (); Initialized?.Invoke (this, EventArgs.Empty); diff --git a/Terminal.Gui/Views/Menu/MenuBar.cs b/Terminal.Gui/Views/Menu/MenuBar.cs index 749ec1a5a2..a9b0676dd8 100644 --- a/Terminal.Gui/Views/Menu/MenuBar.cs +++ b/Terminal.Gui/Views/Menu/MenuBar.cs @@ -1031,7 +1031,7 @@ internal bool Run (Action? action) return false; } - Application.MainLoop!.AddIdle ( + Application.AddIdle ( () => { action (); diff --git a/UICatalog/Properties/launchSettings.json b/UICatalog/Properties/launchSettings.json index 1c37ced48c..0ddda42a4b 100644 --- a/UICatalog/Properties/launchSettings.json +++ b/UICatalog/Properties/launchSettings.json @@ -11,6 +11,14 @@ "commandName": "Project", "commandLineArgs": "--driver WindowsDriver" }, + "UICatalog --driver v2win": { + "commandName": "Project", + "commandLineArgs": "--driver v2win" + }, + "UICatalog --driver v2net": { + "commandName": "Project", + "commandLineArgs": "--driver v2net" + }, "WSL: UICatalog": { "commandName": "Executable", "executablePath": "wsl", diff --git a/UICatalog/Scenario.cs b/UICatalog/Scenario.cs index 02d9c98df1..c460675c25 100644 --- a/UICatalog/Scenario.cs +++ b/UICatalog/Scenario.cs @@ -193,15 +193,19 @@ private void OnApplicationOnInitializedChanged (object? s, EventArgs<bool> a) Application.Iteration += OnApplicationOnIteration; Application.Driver!.ClearedContents += (sender, args) => BenchmarkResults.ClearedContentCount++; - Application.Driver!.Refreshed += (sender, args) => + + if (Application.Driver is ConsoleDriver cd) { - BenchmarkResults.RefreshedCount++; + cd.Refreshed += (sender, args) => + { + BenchmarkResults.RefreshedCount++; + if (args.CurrentValue) + { + BenchmarkResults.UpdatedCount++; + } + }; - if (args.CurrentValue) - { - BenchmarkResults.UpdatedCount++; - } - }; + } Application.NotifyNewRunState += OnApplicationNotifyNewRunState; diff --git a/UICatalog/Scenarios/Keys.cs b/UICatalog/Scenarios/Keys.cs index f2796a344d..b4988a3c93 100644 --- a/UICatalog/Scenarios/Keys.cs +++ b/UICatalog/Scenarios/Keys.cs @@ -12,6 +12,7 @@ public override void Main () Application.Init (); ObservableCollection<string> keyDownList = []; ObservableCollection<string> keyDownNotHandledList = new (); + ObservableCollection<string> swallowedList = new (); var win = new Window { Title = GetQuitKeyAndName () }; @@ -137,6 +138,32 @@ public override void Main () onKeyDownNotHandledListView.ColorScheme = Colors.ColorSchemes ["TopLevel"]; win.Add (onKeyDownNotHandledListView); + + // Swallowed + label = new Label + { + X = Pos.Right (onKeyDownNotHandledListView) + 1, + Y = Pos.Top (label), + Text = "Swallowed:" + }; + win.Add (label); + + var onSwallowedListView = new ListView + { + X = Pos.Left (label), + Y = Pos.Bottom (label), + Width = maxKeyString, + Height = Dim.Fill (), + Source = new ListWrapper<string> (swallowedList) + }; + onSwallowedListView.ColorScheme = Colors.ColorSchemes ["TopLevel"]; + win.Add (onSwallowedListView); + + if (Application.Driver is IConsoleDriverFacade fac) + { + fac.InputProcessor.AnsiSequenceSwallowed += (s, e) => { swallowedList.Add (e.Replace ("\x1b","Esc")); }; + } + Application.KeyDown += (s, a) => KeyDownPressUp (a, "Down"); Application.KeyUp += (s, a) => KeyDownPressUp (a, "Up"); diff --git a/UICatalog/UICatalog.cs b/UICatalog/UICatalog.cs index b9ce42d927..8b2ca2b684 100644 --- a/UICatalog/UICatalog.cs +++ b/UICatalog/UICatalog.cs @@ -16,9 +16,14 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Serilog; using Terminal.Gui; using static Terminal.Gui.ConfigurationManager; using Command = Terminal.Gui.Command; +using ILogger = Microsoft.Extensions.Logging.ILogger; using RuntimeEnvironment = Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment; #nullable enable @@ -118,6 +123,8 @@ private static void ConfigFileChanged (object sender, FileSystemEventArgs e) private static int Main (string [] args) { + Logging.Logger = CreateLogger (); + Console.OutputEncoding = Encoding.Default; if (Debugger.IsAttached) @@ -204,6 +211,28 @@ private static int Main (string [] args) return 0; } + private static ILogger CreateLogger () + { + // Configure Serilog to write logs to a file + Log.Logger = new LoggerConfiguration () + .MinimumLevel.Verbose () // Verbose includes Trace and Debug + .Enrich.FromLogContext () // Enables dynamic enrichment + .WriteTo.File ("logs/logfile.txt", rollingInterval: RollingInterval.Day, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger (); + + // Create a logger factory compatible with Microsoft.Extensions.Logging + using var loggerFactory = LoggerFactory.Create (builder => + { + builder + .AddSerilog (dispose: true) // Integrate Serilog with ILogger + .SetMinimumLevel (LogLevel.Trace); // Set minimum log level + }); + + // Get an ILogger instance + return loggerFactory.CreateLogger ("Global Logger"); + } + private static void OpenUrl (string url) { if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) @@ -896,6 +925,12 @@ public UICatalogTopLevel () public void ConfigChanged () { + if (MenuBar == null) + { + // View is probably disposed + return; + } + if (_topLevelColorScheme == null || !Colors.ColorSchemes.ContainsKey (_topLevelColorScheme)) { _topLevelColorScheme = "Base"; diff --git a/UICatalog/UICatalog.csproj b/UICatalog/UICatalog.csproj index fb12478862..c854ef8e68 100644 --- a/UICatalog/UICatalog.csproj +++ b/UICatalog/UICatalog.csproj @@ -31,6 +31,9 @@ <ItemGroup> <PackageReference Include="JetBrains.Annotations" Version="[2024.2.0,)" PrivateAssets="all" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="[1.21,2)" /> + <PackageReference Include="Serilog" Version="4.2.0" /> + <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" /> + <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="SixLabors.ImageSharp" Version="[3.1.5,4)" /> <PackageReference Include="CsvHelper" Version="[33.0.1,34)" /> <PackageReference Include="Microsoft.DotNet.PlatformAbstractions" Version="[3.1.6,4)" /> diff --git a/UnitTests/Application/MainLoopTests.cs b/UnitTests/Application/MainLoopTests.cs index 47812926d2..8204142746 100644 --- a/UnitTests/Application/MainLoopTests.cs +++ b/UnitTests/Application/MainLoopTests.cs @@ -58,47 +58,47 @@ public void AddIdle_Adds_And_Removes () ml.AddIdle (fnTrue); ml.AddIdle (fnFalse); - Assert.Equal (2, ml.IdleHandlers.Count); - Assert.Equal (fnTrue, ml.IdleHandlers [0]); - Assert.NotEqual (fnFalse, ml.IdleHandlers [0]); + Assert.Equal (2, ml.TimedEvents.IdleHandlers.Count); + Assert.Equal (fnTrue, ml.TimedEvents.IdleHandlers [0]); + Assert.NotEqual (fnFalse, ml.TimedEvents.IdleHandlers[0]); - Assert.True (ml.RemoveIdle (fnTrue)); - Assert.Single (ml.IdleHandlers); + Assert.True (ml.TimedEvents.RemoveIdle (fnTrue)); + Assert.Single (ml.TimedEvents.IdleHandlers); // BUGBUG: This doesn't throw or indicate an error. Ideally RemoveIdle would either // throw an exception in this case, or return an error. // No. Only need to return a boolean. - Assert.False (ml.RemoveIdle (fnTrue)); + Assert.False (ml.TimedEvents.RemoveIdle (fnTrue)); - Assert.True (ml.RemoveIdle (fnFalse)); + Assert.True (ml.TimedEvents.RemoveIdle (fnFalse)); // BUGBUG: This doesn't throw an exception or indicate an error. Ideally RemoveIdle would either // throw an exception in this case, or return an error. // No. Only need to return a boolean. - Assert.False (ml.RemoveIdle (fnFalse)); + Assert.False (ml.TimedEvents.RemoveIdle (fnFalse)); // Add again, but with dupe ml.AddIdle (fnTrue); ml.AddIdle (fnTrue); - Assert.Equal (2, ml.IdleHandlers.Count); - Assert.Equal (fnTrue, ml.IdleHandlers [0]); - Assert.True (ml.IdleHandlers [0] ()); - Assert.Equal (fnTrue, ml.IdleHandlers [1]); - Assert.True (ml.IdleHandlers [1] ()); + Assert.Equal (2, ml.TimedEvents.IdleHandlers.Count); + Assert.Equal (fnTrue, ml.TimedEvents.IdleHandlers[0]); + Assert.True (ml.TimedEvents.IdleHandlers[0] ()); + Assert.Equal (fnTrue, ml.TimedEvents.IdleHandlers[1]); + Assert.True (ml.TimedEvents.IdleHandlers[1] ()); - Assert.True (ml.RemoveIdle (fnTrue)); - Assert.Single (ml.IdleHandlers); - Assert.Equal (fnTrue, ml.IdleHandlers [0]); - Assert.NotEqual (fnFalse, ml.IdleHandlers [0]); + Assert.True (ml.TimedEvents.RemoveIdle (fnTrue)); + Assert.Single (ml.TimedEvents.IdleHandlers); + Assert.Equal (fnTrue, ml.TimedEvents.IdleHandlers[0]); + Assert.NotEqual (fnFalse, ml.TimedEvents.IdleHandlers[0]); - Assert.True (ml.RemoveIdle (fnTrue)); - Assert.Empty (ml.IdleHandlers); + Assert.True (ml.TimedEvents.RemoveIdle (fnTrue)); + Assert.Empty (ml.TimedEvents.IdleHandlers); // BUGBUG: This doesn't throw an exception or indicate an error. Ideally RemoveIdle would either // throw an exception in this case, or return an error. // No. Only need to return a boolean. - Assert.False (ml.RemoveIdle (fnTrue)); + Assert.False (ml.TimedEvents.RemoveIdle (fnTrue)); } [Fact] @@ -153,9 +153,9 @@ public void AddIdle_Twice_Returns_False_Called_Twice () ml.AddIdle (fn1); ml.AddIdle (fn1); ml.Run (); - Assert.True (ml.RemoveIdle (fnStop)); - Assert.False (ml.RemoveIdle (fn1)); - Assert.False (ml.RemoveIdle (fn1)); + Assert.True (ml.TimedEvents.RemoveIdle (fnStop)); + Assert.False (ml.TimedEvents.RemoveIdle (fn1)); + Assert.False (ml.TimedEvents.RemoveIdle (fn1)); Assert.Equal (2, functionCalled); } @@ -178,20 +178,20 @@ public void AddIdleTwice_Function_CalledTwice () ml.AddIdle (fn); ml.RunIteration (); Assert.Equal (2, functionCalled); - Assert.Equal (2, ml.IdleHandlers.Count); + Assert.Equal (2, ml.TimedEvents.IdleHandlers.Count); functionCalled = 0; - Assert.True (ml.RemoveIdle (fn)); - Assert.Single (ml.IdleHandlers); + Assert.True (ml.TimedEvents.RemoveIdle (fn)); + Assert.Single (ml.TimedEvents.IdleHandlers); ml.RunIteration (); Assert.Equal (1, functionCalled); functionCalled = 0; - Assert.True (ml.RemoveIdle (fn)); - Assert.Empty (ml.IdleHandlers); + Assert.True (ml.TimedEvents.RemoveIdle (fn)); + Assert.Empty (ml.TimedEvents.IdleHandlers); ml.RunIteration (); Assert.Equal (0, functionCalled); - Assert.False (ml.RemoveIdle (fn)); + Assert.False (ml.TimedEvents.RemoveIdle (fn)); } [Fact] @@ -209,7 +209,7 @@ public void AddThenRemoveIdle_Function_NotCalled () }; ml.AddIdle (fn); - Assert.True (ml.RemoveIdle (fn)); + Assert.True (ml.TimedEvents.RemoveIdle (fn)); ml.RunIteration (); Assert.Equal (0, functionCalled); } @@ -230,13 +230,13 @@ public void AddTimer_Adds_Removes_NoFaults () return true; }; - object token = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); + object token = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); - Assert.True (ml.RemoveTimeout (token)); + Assert.True (ml.TimedEvents.RemoveTimeout (token)); // BUGBUG: This should probably fault? // Must return a boolean. - Assert.False (ml.RemoveTimeout (token)); + Assert.False (ml.TimedEvents.RemoveTimeout (token)); } [Fact] @@ -260,8 +260,8 @@ public async Task AddTimer_Duplicate_Keys_Not_Allowed () return true; }; - var task1 = new Task (() => token1 = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback)); - var task2 = new Task (() => token2 = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback)); + var task1 = new Task (() => token1 = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback)); + var task2 = new Task (() => token2 = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback)); Assert.Null (token1); Assert.Null (token2); task1.Start (); @@ -270,8 +270,8 @@ public async Task AddTimer_Duplicate_Keys_Not_Allowed () Assert.NotNull (token1); Assert.NotNull (token2); await Task.WhenAll (task1, task2); - Assert.True (ml.RemoveTimeout (token1)); - Assert.True (ml.RemoveTimeout (token2)); + Assert.True (ml.TimedEvents.RemoveTimeout (token1)); + Assert.True (ml.TimedEvents.RemoveTimeout (token2)); Assert.Equal (2, callbackCount); } @@ -297,15 +297,15 @@ public void AddTimer_EventFired () object sender = null; TimeoutEventArgs args = null; - ml.TimeoutAdded += (s, e) => + ml.TimedEvents.TimeoutAdded += (s, e) => { sender = s; args = e; }; - object token = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); + object token = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); - Assert.Same (ml, sender); + Assert.Same (ml.TimedEvents, sender); Assert.NotNull (args.Timeout); Assert.True (args.Ticks - originTicks >= 100 * TimeSpan.TicksPerMillisecond); } @@ -332,14 +332,14 @@ public void AddTimer_In_Parallel_Wont_Throw () }; Parallel.Invoke ( - () => token1 = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback), - () => token2 = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback) + () => token1 = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback), + () => token2 = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback) ); ml.Run (); Assert.NotNull (token1); Assert.NotNull (token2); - Assert.True (ml.RemoveTimeout (token1)); - Assert.True (ml.RemoveTimeout (token2)); + Assert.True (ml.TimedEvents.RemoveTimeout (token1)); + Assert.True (ml.TimedEvents.RemoveTimeout (token2)); Assert.Equal (2, callbackCount); } @@ -375,8 +375,8 @@ public void AddTimer_Remove_NotCalled () return true; }; - object token = ml.AddTimeout (ms, callback); - Assert.True (ml.RemoveTimeout (token)); + object token = ml.TimedEvents.AddTimeout (ms, callback); + Assert.True (ml.TimedEvents.RemoveTimeout (token)); ml.Run (); Assert.Equal (0, callbackCount); } @@ -413,11 +413,11 @@ public void AddTimer_ReturnFalse_StopsBeingCalled () return false; }; - object token = ml.AddTimeout (ms, callback); + object token = ml.TimedEvents.AddTimeout (ms, callback); ml.Run (); Assert.Equal (1, callbackCount); Assert.Equal (10, stopCount); - Assert.False (ml.RemoveTimeout (token)); + Assert.False (ml.TimedEvents.RemoveTimeout (token)); } [Fact] @@ -436,9 +436,9 @@ public void AddTimer_Run_Called () return true; }; - object token = ml.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); + object token = ml.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (ms), callback); ml.Run (); - Assert.True (ml.RemoveTimeout (token)); + Assert.True (ml.TimedEvents.RemoveTimeout (token)); Assert.Equal (1, callbackCount); } @@ -461,7 +461,7 @@ public void AddTimer_Run_CalledAtApproximatelyRightTime () return true; }; - object token = ml.AddTimeout (ms, callback); + object token = ml.TimedEvents.AddTimeout (ms, callback); watch.Start (); ml.Run (); @@ -469,7 +469,7 @@ public void AddTimer_Run_CalledAtApproximatelyRightTime () // https://github.com/xunit/assert.xunit/pull/25 Assert.Equal (ms * callbackCount, watch.Elapsed, new MillisecondTolerance (100)); - Assert.True (ml.RemoveTimeout (token)); + Assert.True (ml.TimedEvents.RemoveTimeout (token)); Assert.Equal (1, callbackCount); } @@ -495,7 +495,7 @@ public void AddTimer_Run_CalledTwiceApproximatelyRightTime () return true; }; - object token = ml.AddTimeout (ms, callback); + object token = ml.TimedEvents.AddTimeout (ms, callback); watch.Start (); ml.Run (); @@ -503,7 +503,7 @@ public void AddTimer_Run_CalledTwiceApproximatelyRightTime () // https://github.com/xunit/assert.xunit/pull/25 Assert.Equal (ms * callbackCount, watch.Elapsed, new MillisecondTolerance (100)); - Assert.True (ml.RemoveTimeout (token)); + Assert.True (ml.TimedEvents.RemoveTimeout (token)); Assert.Equal (2, callbackCount); } @@ -511,7 +511,7 @@ public void AddTimer_Run_CalledTwiceApproximatelyRightTime () public void CheckTimersAndIdleHandlers_NoTimers_Returns_False () { var ml = new MainLoop (new FakeMainLoop ()); - bool retVal = ml.CheckTimersAndIdleHandlers (out int waitTimeOut); + bool retVal = ml.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeOut); Assert.False (retVal); Assert.Equal (-1, waitTimeOut); } @@ -523,7 +523,7 @@ public void CheckTimersAndIdleHandlers_NoTimers_WithIdle_Returns_True () Func<bool> fnTrue = () => true; ml.AddIdle (fnTrue); - bool retVal = ml.CheckTimersAndIdleHandlers (out int waitTimeOut); + bool retVal = ml.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeOut); Assert.True (retVal); Assert.Equal (-1, waitTimeOut); } @@ -536,8 +536,8 @@ public void CheckTimersAndIdleHandlers_With1Timer_Returns_Timer () static bool Callback () { return false; } - _ = ml.AddTimeout (ms, Callback); - bool retVal = ml.CheckTimersAndIdleHandlers (out int waitTimeOut); + _ = ml.TimedEvents.AddTimeout (ms, Callback); + bool retVal = ml.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeOut); Assert.True (retVal); @@ -553,9 +553,9 @@ public void CheckTimersAndIdleHandlers_With2Timers_Returns_Timer () static bool Callback () { return false; } - _ = ml.AddTimeout (ms, Callback); - _ = ml.AddTimeout (ms, Callback); - bool retVal = ml.CheckTimersAndIdleHandlers (out int waitTimeOut); + _ = ml.TimedEvents.AddTimeout (ms, Callback); + _ = ml.TimedEvents.AddTimeout (ms, Callback); + bool retVal = ml.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeOut); Assert.True (retVal); @@ -600,8 +600,8 @@ public void False_Idle_Stops_It_Being_Called_Again () ml.AddIdle (fnStop); ml.AddIdle (fn1); ml.Run (); - Assert.True (ml.RemoveIdle (fnStop)); - Assert.False (ml.RemoveIdle (fn1)); + Assert.True (ml.TimedEvents.RemoveIdle (fnStop)); + Assert.False (ml.TimedEvents.RemoveIdle (fn1)); Assert.Equal (10, functionCalled); Assert.Equal (20, stopCount); @@ -612,8 +612,8 @@ public void Internal_Tests () { var testMainloop = new TestMainloop (); var mainloop = new MainLoop (testMainloop); - Assert.Empty (mainloop._timeouts); - Assert.Empty (mainloop._idleHandlers); + Assert.Empty (mainloop.TimedEvents.Timeouts); + Assert.Empty (mainloop.TimedEvents.IdleHandlers); Assert.NotNull ( new Timeout { Span = new TimeSpan (), Callback = () => true } @@ -748,7 +748,7 @@ public void RemoveIdle_Function_NotCalled () return true; }; - Assert.False (ml.RemoveIdle (fn)); + Assert.False (ml.TimedEvents.RemoveIdle (fn)); ml.RunIteration (); Assert.Equal (0, functionCalled); } @@ -774,7 +774,7 @@ public void Run_Runs_Idle_Stop_Stops_Idle () ml.AddIdle (fn); ml.Run (); - Assert.True (ml.RemoveIdle (fn)); + Assert.True (ml.TimedEvents.RemoveIdle (fn)); Assert.Equal (10, functionCalled); } diff --git a/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs b/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs new file mode 100644 index 0000000000..1554ac65e3 --- /dev/null +++ b/UnitTests/ConsoleDrivers/AnsiKeyboardParserTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UnitTests.ConsoleDrivers; +public class AnsiKeyboardParserTests +{ + private readonly AnsiKeyboardParser _parser = new (); + + public static IEnumerable<object []> GetKeyboardTestData () + { + // Test data for various ANSI escape sequences and their expected Key values + yield return new object [] { "\u001b[A", Key.CursorUp }; + yield return new object [] { "\u001b[B", Key.CursorDown }; + yield return new object [] { "\u001b[C", Key.CursorRight }; + yield return new object [] { "\u001b[D", Key.CursorLeft }; + + // Valid inputs with modifiers + yield return new object [] { "\u001b[1;2A", Key.CursorUp.WithShift }; + yield return new object [] { "\u001b[1;3A", Key.CursorUp.WithAlt }; + yield return new object [] { "\u001b[1;4A", Key.CursorUp.WithAlt.WithShift }; + yield return new object [] { "\u001b[1;5A", Key.CursorUp.WithCtrl }; + yield return new object [] { "\u001b[1;6A", Key.CursorUp.WithCtrl.WithShift }; + yield return new object [] { "\u001b[1;7A", Key.CursorUp.WithCtrl.WithAlt }; + yield return new object [] { "\u001b[1;8A", Key.CursorUp.WithCtrl.WithAlt.WithShift }; + + yield return new object [] { "\u001b[1;2B", Key.CursorDown.WithShift }; + yield return new object [] { "\u001b[1;3B", Key.CursorDown.WithAlt }; + yield return new object [] { "\u001b[1;4B", Key.CursorDown.WithAlt.WithShift }; + yield return new object [] { "\u001b[1;5B", Key.CursorDown.WithCtrl }; + yield return new object [] { "\u001b[1;6B", Key.CursorDown.WithCtrl.WithShift }; + yield return new object [] { "\u001b[1;7B", Key.CursorDown.WithCtrl.WithAlt }; + yield return new object [] { "\u001b[1;8B", Key.CursorDown.WithCtrl.WithAlt.WithShift }; + + yield return new object [] { "\u001b[1;2C", Key.CursorRight.WithShift }; + yield return new object [] { "\u001b[1;3C", Key.CursorRight.WithAlt }; + yield return new object [] { "\u001b[1;4C", Key.CursorRight.WithAlt.WithShift }; + yield return new object [] { "\u001b[1;5C", Key.CursorRight.WithCtrl }; + yield return new object [] { "\u001b[1;6C", Key.CursorRight.WithCtrl.WithShift }; + yield return new object [] { "\u001b[1;7C", Key.CursorRight.WithCtrl.WithAlt }; + yield return new object [] { "\u001b[1;8C", Key.CursorRight.WithCtrl.WithAlt.WithShift }; + + yield return new object [] { "\u001b[1;2D", Key.CursorLeft.WithShift }; + yield return new object [] { "\u001b[1;3D", Key.CursorLeft.WithAlt }; + yield return new object [] { "\u001b[1;4D", Key.CursorLeft.WithAlt.WithShift }; + yield return new object [] { "\u001b[1;5D", Key.CursorLeft.WithCtrl }; + yield return new object [] { "\u001b[1;6D", Key.CursorLeft.WithCtrl.WithShift }; + yield return new object [] { "\u001b[1;7D", Key.CursorLeft.WithCtrl.WithAlt }; + yield return new object [] { "\u001b[1;8D", Key.CursorLeft.WithCtrl.WithAlt.WithShift }; + + + // Invalid inputs + yield return new object [] { "\u001b[Z", null }; + yield return new object [] { "\u001b[invalid", null }; + yield return new object [] { "\u001b[1", null }; + yield return new object [] { "\u001b[AB", null }; + yield return new object [] { "\u001b[;A", null }; + } + + // Consolidated test for all keyboard events (e.g., arrow keys) + [Theory] + [MemberData (nameof (GetKeyboardTestData))] + public void ProcessKeyboardInput_ReturnsCorrectKey (string input, Key? expectedKey) + { + // Act + Key? result = _parser.IsKeyboard (input)?.GetKey (input); + + // Assert + Assert.Equal (expectedKey, result); // Verify the returned key matches the expected one + } +} diff --git a/UnitTests/ConsoleDrivers/AnsiMouseParserTests.cs b/UnitTests/ConsoleDrivers/AnsiMouseParserTests.cs new file mode 100644 index 0000000000..c7bb046210 --- /dev/null +++ b/UnitTests/ConsoleDrivers/AnsiMouseParserTests.cs @@ -0,0 +1,42 @@ +namespace UnitTests.ConsoleDrivers; + +public class AnsiMouseParserTests +{ + private readonly AnsiMouseParser _parser; + + public AnsiMouseParserTests () { _parser = new (); } + + // Consolidated test for all mouse events: button press/release, wheel scroll, position, modifiers + [Theory] + [InlineData ("\u001b[<0;100;200M", 99, 199, MouseFlags.Button1Pressed)] // Button 1 Pressed + [InlineData ("\u001b[<0;150;250m", 149, 249, MouseFlags.Button1Released)] // Button 1 Released + [InlineData ("\u001b[<1;120;220M", 119, 219, MouseFlags.Button2Pressed)] // Button 2 Pressed + [InlineData ("\u001b[<1;180;280m", 179, 279, MouseFlags.Button2Released)] // Button 2 Released + [InlineData ("\u001b[<2;200;300M", 199, 299, MouseFlags.Button3Pressed)] // Button 3 Pressed + [InlineData ("\u001b[<2;250;350m", 249, 349, MouseFlags.Button3Released)] // Button 3 Released + [InlineData ("\u001b[<64;100;200M", 99, 199, MouseFlags.WheeledUp)] // Wheel Scroll Up + [InlineData ("\u001b[<65;150;250m", 149, 249, MouseFlags.WheeledDown)] // Wheel Scroll Down + [InlineData ("\u001b[<39;100;200m", 99, 199, MouseFlags.ButtonShift | MouseFlags.ReportMousePosition)] // Mouse Position (No Button) + [InlineData ("\u001b[<43;120;240m", 119, 239, MouseFlags.ButtonAlt | MouseFlags.ReportMousePosition)] // Mouse Position (No Button) + [InlineData ("\u001b[<8;100;200M", 99, 199, MouseFlags.Button1Pressed | MouseFlags.ButtonAlt)] // Button 1 Pressed + Alt + [InlineData ("\u001b[<invalid;100;200M", 0, 0, MouseFlags.None)] // Invalid Input (Expecting null) + [InlineData ("\u001b[<100;200;300Z", 0, 0, MouseFlags.None)] // Invalid Input (Expecting null) + [InlineData ("\u001b[<invalidInput>", 0, 0, MouseFlags.None)] // Invalid Input (Expecting null) + public void ProcessMouseInput_ReturnsCorrectFlags (string input, int expectedX, int expectedY, MouseFlags expectedFlags) + { + // Act + MouseEventArgs result = _parser.ProcessMouseInput (input); + + // Assert + if (expectedFlags == MouseFlags.None) + { + Assert.Null (result); // Expect null for invalid inputs + } + else + { + Assert.NotNull (result); // Expect non-null result for valid inputs + Assert.Equal (new (expectedX, expectedY), result!.Position); // Verify position + Assert.Equal (expectedFlags, result.Flags); // Verify flags + } + } +} diff --git a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs index fbc87761f5..fcc2b6127d 100644 --- a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs +++ b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs @@ -153,7 +153,7 @@ public static IEnumerable<object []> TestInputSequencesExact_Cases () null, new [] { - new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty) + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingEscapeSequence,string.Empty) } ]; @@ -163,13 +163,13 @@ public static IEnumerable<object []> TestInputSequencesExact_Cases () 'c', new [] { - new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), - new StepExpectation ('H',AnsiResponseParserState.Normal,"\u001bH"), // H is known terminator and not expected one so here we release both chars - new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingEscapeSequence,string.Empty), + new StepExpectation ('H',AnsiResponseParserState.InResponse,string.Empty), // H is known terminator and not expected one so here we release both chars + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingEscapeSequence,"\u001bH"), new StepExpectation ('[',AnsiResponseParserState.InResponse,string.Empty), new StepExpectation ('0',AnsiResponseParserState.InResponse,string.Empty), new StepExpectation ('c',AnsiResponseParserState.Normal,string.Empty,"\u001b[0c"), // c is expected terminator so here we swallow input and populate expected response - new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingEscapeSequence,string.Empty), } ]; } @@ -227,8 +227,10 @@ public void TestInputSequencesExact (string caseName, char? terminator, IEnumera { parser.ExpectResponse (terminator.Value.ToString (),(s)=> response = s,null, false); } + int step= 0; foreach (var state in expectedStates) { + step++; // If we expect the response to be detected at this step if (!string.IsNullOrWhiteSpace (state.ExpectedAnsiResponse)) { @@ -247,6 +249,8 @@ public void TestInputSequencesExact (string caseName, char? terminator, IEnumera // And after passing input it shuld be the expected value Assert.Equal (state.ExpectedAnsiResponse, response); } + + output.WriteLine ($"Step {step} passed"); } } @@ -260,8 +264,8 @@ public void ReleasesEscapeAfterTimeout () AssertConsumed (input,ref i); // We should know when the state changed - Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser1.State); - Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser2.State); + Assert.Equal (AnsiResponseParserState.ExpectingEscapeSequence, _parser1.State); + Assert.Equal (AnsiResponseParserState.ExpectingEscapeSequence, _parser2.State); Assert.Equal (DateTime.Now.Date, _parser1.StateChangedAt.Date); Assert.Equal (DateTime.Now.Date, _parser2.StateChangedAt.Date); @@ -289,35 +293,6 @@ public void TwoExcapesInARow () AssertManualReleaseIs ("\u001b",1); } - [Fact] - public void TwoExcapesInARowWithTextBetween () - { - // Example user presses Esc key and types at the speed of light (normally the consumer should be handling Esc timeout) - // then a DAR comes in. - string input = "\u001bfish\u001b"; - int i = 0; - - // First Esc gets grabbed - AssertConsumed (input, ref i); // Esc - Assert.Equal (AnsiResponseParserState.ExpectingBracket,_parser1.State); - Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser2.State); - - // Because next char is 'f' we do not see a bracket so release both - AssertReleased (input, ref i, "\u001bf", 0,1); // f - - Assert.Equal (AnsiResponseParserState.Normal, _parser1.State); - Assert.Equal (AnsiResponseParserState.Normal, _parser2.State); - - AssertReleased (input, ref i,"i",2); - AssertReleased (input, ref i, "s", 3); - AssertReleased (input, ref i, "h", 4); - - AssertConsumed (input, ref i); // Second Esc - - // Assume 50ms or something has passed, lets force release as no new content - AssertManualReleaseIs ("\u001b", 5); - } - [Fact] public void TestLateResponses () { @@ -474,6 +449,191 @@ public void UnknownResponses_ParameterShouldMatch () Assert.Equal (expectedUnknownResponses, unknownResponses); } + [Fact] + public void ParserDetectsMouse () + { + // ANSI escape sequence for mouse down (using a generic format example) + const string MOUSE_DOWN = "\u001B[<0;12;32M"; + + // ANSI escape sequence for Device Attribute Response (e.g., Terminal identifying itself) + const string DEVICE_ATTRIBUTE_RESPONSE = "\u001B[?1;2c"; + + // ANSI escape sequence for mouse up (using a generic format example) + const string MOUSE_UP = "\u001B[<0;25;50m"; + + var parser = new AnsiResponseParser (); + + parser.HandleMouse = true; + string? foundDar = null; + List<MouseEventArgs> mouseEventArgs = new (); + + parser.Mouse += (s, e) => mouseEventArgs.Add (e); + parser.ExpectResponse ("c", (dar) => foundDar = dar, null, false); + var released = parser.ProcessInput ("a" + MOUSE_DOWN + "asdf" + DEVICE_ATTRIBUTE_RESPONSE + "bbcc" + MOUSE_UP + "sss"); + + Assert.Equal ("aasdfbbccsss", released); + + Assert.Equal (2, mouseEventArgs.Count); + + Assert.NotNull (foundDar); + Assert.Equal (DEVICE_ATTRIBUTE_RESPONSE,foundDar); + + Assert.True (mouseEventArgs [0].IsPressed); + // Mouse positions in ANSI are 1 based so actual Terminal.Gui Screen positions are x-1,y-1 + Assert.Equal (11,mouseEventArgs [0].Position.X); + Assert.Equal (31, mouseEventArgs [0].Position.Y); + + Assert.True (mouseEventArgs [1].IsReleased); + Assert.Equal (24, mouseEventArgs [1].Position.X); + Assert.Equal (49, mouseEventArgs [1].Position.Y); + } + + + [Fact] + public void ParserDetectsKeyboard () + { + + // ANSI escape sequence for cursor left + const string LEFT = "\u001b[D"; + + // ANSI escape sequence for Device Attribute Response (e.g., Terminal identifying itself) + const string DEVICE_ATTRIBUTE_RESPONSE = "\u001B[?1;2c"; + + // ANSI escape sequence for cursor up (while shift held down) + const string SHIFT_UP = "\u001b[1;2A"; + + var parser = new AnsiResponseParser (); + + parser.HandleKeyboard = true; + string? foundDar = null; + List<Key> keys = new (); + + parser.Keyboard += (s, e) => keys.Add (e); + parser.ExpectResponse ("c", (dar) => foundDar = dar, null, false); + var released = parser.ProcessInput ("a" + LEFT + "asdf" + DEVICE_ATTRIBUTE_RESPONSE + "bbcc" + SHIFT_UP + "sss"); + + Assert.Equal ("aasdfbbccsss", released); + + Assert.Equal (2, keys.Count); + + Assert.NotNull (foundDar); + Assert.Equal (DEVICE_ATTRIBUTE_RESPONSE, foundDar); + + Assert.Equal (Key.CursorLeft,keys [0]); + Assert.Equal (Key.CursorUp.WithShift, keys [1]); + } + + public static IEnumerable<object []> ParserDetects_FunctionKeys_Cases () + { + // These are VT100 escape codes for F1-4 + yield return + [ + "\u001bOP", + Key.F1 + ]; + + yield return + [ + "\u001bOQ", + Key.F2 + ]; + + yield return + [ + "\u001bOR", + Key.F3 + ]; + + yield return + [ + "\u001bOS", + Key.F4 + ]; + + + // These are also F keys + yield return [ + "\u001b[11~", + Key.F1 + ]; + + yield return [ + "\u001b[12~", + Key.F2 + ]; + + yield return [ + "\u001b[13~", + Key.F3 + ]; + + yield return [ + "\u001b[14~", + Key.F4 + ]; + + yield return [ + "\u001b[15~", + Key.F5 + ]; + + yield return [ + "\u001b[17~", + Key.F6 + ]; + + yield return [ + "\u001b[18~", + Key.F7 + ]; + + yield return [ + "\u001b[19~", + Key.F8 + ]; + + yield return [ + "\u001b[20~", + Key.F9 + ]; + + yield return [ + "\u001b[21~", + Key.F10 + ]; + + yield return [ + "\u001b[23~", + Key.F11 + ]; + + yield return [ + "\u001b[24~", + Key.F12 + ]; + } + + [MemberData (nameof (ParserDetects_FunctionKeys_Cases))] + + [Theory] + public void ParserDetects_FunctionKeys (string input, Key expectedKey) + { + var parser = new AnsiResponseParser (); + + parser.HandleKeyboard = true; + List<Key> keys = new (); + + parser.Keyboard += (s, e) => keys.Add (e); + + foreach (var ch in input.ToCharArray ()) + { + parser.ProcessInput (new (ch,1)); + } + var k = Assert.Single (keys); + + Assert.Equal (k,expectedKey); + } + private Tuple<char, int> [] StringToBatch (string batch) { return batch.Select ((k) => Tuple.Create (k, tIndex++)).ToArray (); diff --git a/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs b/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs index d75a56639d..cd957af483 100644 --- a/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs +++ b/UnitTests/ConsoleDrivers/ConsoleDriverTests.cs @@ -221,7 +221,8 @@ public void TerminalResized_Simulation (Type driverType) driver.Cols = 120; driver.Rows = 40; - driver.OnSizeChanged (new SizeChangedEventArgs (new (driver.Cols, driver.Rows))); + + ((ConsoleDriver)driver).OnSizeChanged (new SizeChangedEventArgs (new (driver.Cols, driver.Rows))); Assert.Equal (120, driver.Cols); Assert.Equal (40, driver.Rows); Assert.True (wasTerminalResized); diff --git a/UnitTests/ConsoleDrivers/MainLoopDriverTests.cs b/UnitTests/ConsoleDrivers/MainLoopDriverTests.cs index 9720d51b5e..228d9e5726 100644 --- a/UnitTests/ConsoleDrivers/MainLoopDriverTests.cs +++ b/UnitTests/ConsoleDrivers/MainLoopDriverTests.cs @@ -52,7 +52,7 @@ public void MainLoop_AddTimeout_ValidParameters_ReturnsToken (Type driverType, T var mainLoop = new MainLoop (mainLoopDriver); var callbackInvoked = false; - object token = mainLoop.AddTimeout ( + object token = mainLoop.TimedEvents.AddTimeout ( TimeSpan.FromMilliseconds (100), () => { @@ -88,7 +88,7 @@ Type mainLoopDriverType var mainLoop = new MainLoop (mainLoopDriver); mainLoop.AddIdle (() => false); - bool result = mainLoop.CheckTimersAndIdleHandlers (out int waitTimeout); + bool result = mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeout); Assert.True (result); Assert.Equal (-1, waitTimeout); @@ -111,7 +111,7 @@ Type mainLoopDriverType var mainLoopDriver = (IMainLoopDriver)Activator.CreateInstance (mainLoopDriverType, driver); var mainLoop = new MainLoop (mainLoopDriver); - bool result = mainLoop.CheckTimersAndIdleHandlers (out int waitTimeout); + bool result = mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeout); Assert.False (result); Assert.Equal (-1, waitTimeout); @@ -134,8 +134,8 @@ Type mainLoopDriverType var mainLoopDriver = (IMainLoopDriver)Activator.CreateInstance (mainLoopDriverType, driver); var mainLoop = new MainLoop (mainLoopDriver); - mainLoop.AddTimeout (TimeSpan.FromMilliseconds (100), () => false); - bool result = mainLoop.CheckTimersAndIdleHandlers (out int waitTimeout); + mainLoop.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (100), () => false); + bool result = mainLoop.TimedEvents.CheckTimersAndIdleHandlers (out int waitTimeout); Assert.True (result); Assert.True (waitTimeout >= 0); @@ -158,8 +158,8 @@ public void MainLoop_Constructs_Disposes (Type driverType, Type mainLoopDriverTy // Check default values Assert.NotNull (mainLoop); Assert.Equal (mainLoopDriver, mainLoop.MainLoopDriver); - Assert.Empty (mainLoop.IdleHandlers); - Assert.Empty (mainLoop.Timeouts); + Assert.Empty (mainLoop.TimedEvents.IdleHandlers); + Assert.Empty (mainLoop.TimedEvents.Timeouts); Assert.False (mainLoop.Running); // Clean up @@ -168,8 +168,8 @@ public void MainLoop_Constructs_Disposes (Type driverType, Type mainLoopDriverTy // TODO: It'd be nice if we could really verify IMainLoopDriver.TearDown was called // and that it was actually cleaned up. Assert.Null (mainLoop.MainLoopDriver); - Assert.Empty (mainLoop.IdleHandlers); - Assert.Empty (mainLoop.Timeouts); + Assert.Empty (mainLoop.TimedEvents.IdleHandlers); + Assert.Empty (mainLoop.TimedEvents.Timeouts); Assert.False (mainLoop.Running); } @@ -186,7 +186,7 @@ public void MainLoop_RemoveIdle_InvalidToken_ReturnsFalse (Type driverType, Type var mainLoopDriver = (IMainLoopDriver)Activator.CreateInstance (mainLoopDriverType, driver); var mainLoop = new MainLoop (mainLoopDriver); - bool result = mainLoop.RemoveIdle (() => false); + bool result = mainLoop.TimedEvents.RemoveIdle (() => false); Assert.False (result); mainLoop.Dispose (); @@ -208,7 +208,7 @@ public void MainLoop_RemoveIdle_ValidToken_ReturnsTrue (Type driverType, Type ma bool IdleHandler () { return false; } Func<bool> token = mainLoop.AddIdle (IdleHandler); - bool result = mainLoop.RemoveIdle (token); + bool result = mainLoop.TimedEvents.RemoveIdle (token); Assert.True (result); mainLoop.Dispose (); @@ -227,7 +227,7 @@ public void MainLoop_RemoveTimeout_InvalidToken_ReturnsFalse (Type driverType, T var mainLoopDriver = (IMainLoopDriver)Activator.CreateInstance (mainLoopDriverType, driver); var mainLoop = new MainLoop (mainLoopDriver); - bool result = mainLoop.RemoveTimeout (new object ()); + bool result = mainLoop.TimedEvents.RemoveTimeout (new object ()); Assert.False (result); } @@ -245,8 +245,8 @@ public void MainLoop_RemoveTimeout_ValidToken_ReturnsTrue (Type driverType, Type var mainLoopDriver = (IMainLoopDriver)Activator.CreateInstance (mainLoopDriverType, driver); var mainLoop = new MainLoop (mainLoopDriver); - object token = mainLoop.AddTimeout (TimeSpan.FromMilliseconds (100), () => false); - bool result = mainLoop.RemoveTimeout (token); + object token = mainLoop.TimedEvents.AddTimeout (TimeSpan.FromMilliseconds (100), () => false); + bool result = mainLoop.TimedEvents.RemoveTimeout (token); Assert.True (result); mainLoop.Dispose (); diff --git a/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs b/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs new file mode 100644 index 0000000000..1afd367847 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/ApplicationV2Tests.cs @@ -0,0 +1,287 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Moq; + +namespace UnitTests.ConsoleDrivers.V2; +public class ApplicationV2Tests +{ + + private ApplicationV2 NewApplicationV2 () + { + var netInput = new Mock<INetInput> (); + SetupRunInputMockMethodToBlock (netInput); + var winInput = new Mock<IWindowsInput> (); + SetupRunInputMockMethodToBlock (winInput); + + return new ( + ()=>netInput.Object, + Mock.Of<IConsoleOutput>, + () => winInput.Object, + Mock.Of<IConsoleOutput>); + } + + [Fact] + public void TestInit_CreatesKeybindings () + { + var v2 = NewApplicationV2(); + + Application.KeyBindings.Clear(); + + Assert.Empty(Application.KeyBindings.GetBindings ()); + + v2.Init (); + + Assert.NotEmpty (Application.KeyBindings.GetBindings ()); + + v2.Shutdown (); + } + + [Fact] + public void TestInit_DriverIsFacade () + { + var v2 = NewApplicationV2(); + + Assert.Null (Application.Driver); + v2.Init (); + Assert.NotNull (Application.Driver); + + var type = Application.Driver.GetType (); + Assert.True(type.IsGenericType); + Assert.True (type.GetGenericTypeDefinition () == typeof (ConsoleDriverFacade<>)); + v2.Shutdown (); + + Assert.Null (Application.Driver); + } + + [Fact] + public void TestInit_ExplicitlyRequestWin () + { + var netInput = new Mock<INetInput> (MockBehavior.Strict); + var netOutput = new Mock<IConsoleOutput> (MockBehavior.Strict); + var winInput = new Mock<IWindowsInput> (MockBehavior.Strict); + var winOutput = new Mock<IConsoleOutput> (MockBehavior.Strict); + + winInput.Setup (i => i.Initialize (It.IsAny<ConcurrentQueue<WindowsConsole.InputRecord>> ())) + .Verifiable(Times.Once); + SetupRunInputMockMethodToBlock (winInput); + winInput.Setup (i=>i.Dispose ()) + .Verifiable(Times.Once); + winOutput.Setup (i => i.Dispose ()) + .Verifiable (Times.Once); + + var v2 = new ApplicationV2 ( + ()=> netInput.Object, + () => netOutput.Object, + () => winInput.Object, + () => winOutput.Object); + + Assert.Null (Application.Driver); + v2.Init (null,"v2win"); + Assert.NotNull (Application.Driver); + + var type = Application.Driver.GetType (); + Assert.True (type.IsGenericType); + Assert.True (type.GetGenericTypeDefinition () == typeof (ConsoleDriverFacade<>)); + v2.Shutdown (); + + Assert.Null (Application.Driver); + + winInput.VerifyAll(); + } + + [Fact] + public void TestInit_ExplicitlyRequestNet () + { + var netInput = new Mock<INetInput> (MockBehavior.Strict); + var netOutput = new Mock<IConsoleOutput> (MockBehavior.Strict); + var winInput = new Mock<IWindowsInput> (MockBehavior.Strict); + var winOutput = new Mock<IConsoleOutput> (MockBehavior.Strict); + + netInput.Setup (i => i.Initialize (It.IsAny<ConcurrentQueue<ConsoleKeyInfo>> ())) + .Verifiable (Times.Once); + SetupRunInputMockMethodToBlock (netInput); + netInput.Setup (i => i.Dispose ()) + .Verifiable (Times.Once); + netOutput.Setup (i => i.Dispose ()) + .Verifiable (Times.Once); + var v2 = new ApplicationV2 ( + () => netInput.Object, + () => netOutput.Object, + () => winInput.Object, + () => winOutput.Object); + + Assert.Null (Application.Driver); + v2.Init (null, "v2net"); + Assert.NotNull (Application.Driver); + + var type = Application.Driver.GetType (); + Assert.True (type.IsGenericType); + Assert.True (type.GetGenericTypeDefinition () == typeof (ConsoleDriverFacade<>)); + v2.Shutdown (); + + Assert.Null (Application.Driver); + + netInput.VerifyAll (); + } + + private void SetupRunInputMockMethodToBlock (Mock<IWindowsInput> winInput) + { + winInput.Setup (r => r.Run (It.IsAny<CancellationToken> ())) + .Callback<CancellationToken> (token => + { + // Simulate an infinite loop that checks for cancellation + while (!token.IsCancellationRequested) + { + // Perform the action that should repeat in the loop + // This could be some mock behavior or just an empty loop depending on the context + } + }) + .Verifiable (Times.Once); + } + private void SetupRunInputMockMethodToBlock (Mock<INetInput> netInput) + { + netInput.Setup (r => r.Run (It.IsAny<CancellationToken> ())) + .Callback<CancellationToken> (token => + { + // Simulate an infinite loop that checks for cancellation + while (!token.IsCancellationRequested) + { + // Perform the action that should repeat in the loop + // This could be some mock behavior or just an empty loop depending on the context + } + }) + .Verifiable (Times.Once); + } + + [Fact] + public void Test_NoInitThrowOnRun () + { + var app = NewApplicationV2(); + + var ex = Assert.Throws<NotInitializedException> (() => app.Run (new Window ())); + Assert.Equal ("Run cannot be accessed before Initialization", ex.Message); + } + + [Fact] + public void Test_InitRunShutdown () + { + var orig = ApplicationImpl.Instance; + + var v2 = NewApplicationV2(); + ApplicationImpl.ChangeInstance (v2); + + v2.Init (); + + var timeoutToken = v2.AddTimeout (TimeSpan.FromMilliseconds (150), + () => + { + if (Application.Top != null) + { + Application.RequestStop (); + return true; + } + + return true; + } + ); + Assert.Null (Application.Top); + + // Blocks until the timeout call is hit + + v2.Run (new Window ()); + + Assert.True(v2.RemoveTimeout (timeoutToken)); + + Assert.Null (Application.Top); + v2.Shutdown (); + + ApplicationImpl.ChangeInstance (orig); + } + + + [Fact] + public void Test_InitRunShutdown_Generic_IdleForExit () + { + var orig = ApplicationImpl.Instance; + + var v2 = NewApplicationV2 (); + ApplicationImpl.ChangeInstance (v2); + + v2.Init (); + + v2.AddIdle (IdleExit); + Assert.Null (Application.Top); + + // Blocks until the timeout call is hit + + v2.Run<Window> (); + + Assert.Null (Application.Top); + v2.Shutdown (); + + ApplicationImpl.ChangeInstance (orig); + } + private bool IdleExit () + { + if (Application.Top != null) + { + Application.RequestStop (); + return true; + } + + return true; + } + + [Fact] + public void TestRepeatedShutdownCalls_DoNotDuplicateDisposeOutput () + { + var netInput = new Mock<INetInput> (); + SetupRunInputMockMethodToBlock (netInput); + Mock<IConsoleOutput>? outputMock = null; + + + var v2 = new ApplicationV2( + () => netInput.Object, + ()=> (outputMock = new Mock<IConsoleOutput>()).Object, + Mock.Of<IWindowsInput>, + Mock.Of<IConsoleOutput>); + + v2.Init (null,"v2net"); + + + v2.Shutdown (); + v2.Shutdown (); + outputMock.Verify(o=>o.Dispose (),Times.Once); + } + [Fact] + public void TestRepeatedInitCalls_WarnsAndIgnores () + { + var v2 = NewApplicationV2 (); + + Assert.Null (Application.Driver); + v2.Init (); + Assert.NotNull (Application.Driver); + + var mockLogger = new Mock<ILogger> (); + + var beforeLogger = Logging.Logger; + Logging.Logger = mockLogger.Object; + + v2.Init (); + v2.Init (); + + mockLogger.Verify( + l=>l.Log (LogLevel.Error, + It.IsAny<EventId> (), + It.Is<It.IsAnyType> ((v, t) => v.ToString () == "Init called multiple times without shutdown, ignoring."), + It.IsAny<Exception> (), + It.IsAny<Func<It.IsAnyType, Exception, string>> ()) + ,Times.Exactly (2)); + + v2.Shutdown (); + + // Restore the original null logger to be polite to other tests + Logging.Logger = beforeLogger; + } + +} diff --git a/UnitTests/ConsoleDrivers/V2/ConsoleInputTests.cs b/UnitTests/ConsoleDrivers/V2/ConsoleInputTests.cs new file mode 100644 index 0000000000..888cd5943b --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/ConsoleInputTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UnitTests.ConsoleDrivers.V2; +public class ConsoleInputTests +{ + class FakeInput : ConsoleInput<char> + { + private readonly string [] _reads; + + public FakeInput (params string [] reads ) { _reads = reads; } + + int iteration = 0; + /// <inheritdoc /> + protected override bool Peek () + { + return iteration < _reads.Length; + } + + /// <inheritdoc /> + protected override IEnumerable<char> Read () + { + return _reads [iteration++]; + } + } + + [Fact] + public void Test_ThrowsIfNotInitialized () + { + var input = new FakeInput ("Fish"); + + var ex = Assert.Throws<Exception>(()=>input.Run (new (canceled: true))); + Assert.Equal ("Cannot run input before Initialization", ex.Message); + } + + + [Fact] + public void Test_Simple () + { + var input = new FakeInput ("Fish"); + var queue = new ConcurrentQueue<char> (); + + input.Initialize (queue); + + var cts = new CancellationTokenSource (); + cts.CancelAfter (25); // Cancel after 25 milliseconds + CancellationToken token = cts.Token; + Assert.Empty (queue); + input.Run (token); + + Assert.Equal ("Fish",new string (queue.ToArray ())); + } +} diff --git a/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs b/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs new file mode 100644 index 0000000000..d678c93e97 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/MainLoopCoordinatorTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Moq; + +namespace UnitTests.ConsoleDrivers.V2; +public class MainLoopCoordinatorTests +{ + [Fact] + public void TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThread () + { + + var mockLogger = new Mock<ILogger> (); + + var beforeLogger = Logging.Logger; + Logging.Logger = mockLogger.Object; + + var c = new MainLoopCoordinator<char> (new TimedEvents (), + // Runs on a separate thread (input thread) + () => throw new Exception ("Crash on boot"), + + // Rest runs on main thread + new ConcurrentQueue<char> (), + Mock.Of <IInputProcessor>(), + ()=>Mock.Of<IConsoleOutput>(), + Mock.Of<IMainLoop<char>>()); + + // StartAsync boots the main loop and the input thread. But if the input class bombs + // on startup it is important that the exception surface at the call site and not lost + var ex = Assert.ThrowsAsync<AggregateException>(c.StartAsync).Result; + Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message); + + + // Restore the original null logger to be polite to other tests + Logging.Logger = beforeLogger; + + + // Logs should explicitly call out that input loop crashed. + mockLogger.Verify ( + l => l.Log (LogLevel.Critical, + It.IsAny<EventId> (), + It.Is<It.IsAnyType> ((v, t) => v.ToString () == "Input loop crashed"), + It.IsAny<Exception> (), + It.IsAny<Func<It.IsAnyType, Exception, string>> ()) + , Times.Once); + } + /* + [Fact] + public void TestMainLoopCoordinator_InputExitsImmediately_ExceptionRaisedInMainThread () + { + + // Runs on a separate thread (input thread) + // But because it's just a mock it immediately exists + var mockInputFactoryMethod = () => Mock.Of<IConsoleInput<char>> (); + + + var mockOutput = Mock.Of<IConsoleOutput> (); + var mockInputProcessor = Mock.Of<IInputProcessor> (); + var inputQueue = new ConcurrentQueue<char> (); + var timedEvents = new TimedEvents (); + + var mainLoop = new MainLoop<char> (); + mainLoop.Initialize (timedEvents, + inputQueue, + mockInputProcessor, + mockOutput + ); + + var c = new MainLoopCoordinator<char> (timedEvents, + mockInputFactoryMethod, + inputQueue, + mockInputProcessor, + ()=>mockOutput, + mainLoop + ); + + // TODO: This test has race condition + // + // * When the input loop exits it can happen + // * - During boot + // * - After boot + // * + // * If it happens in boot you get input exited + // * If it happens after you get "Input loop exited early (stop not called)" + // + + // Because the console input class does not block - i.e. breaks contract + // We need to let the user know input has silently exited and all has gone bad. + var ex = Assert.ThrowsAsync<Exception> (c.StartAsync).Result; + Assert.Equal ("Input loop exited during startup instead of entering read loop properly (i.e. and blocking)", ex.Message); + }*/ +} diff --git a/UnitTests/ConsoleDrivers/V2/MainLoopTTests.cs b/UnitTests/ConsoleDrivers/V2/MainLoopTTests.cs new file mode 100644 index 0000000000..727c105536 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/MainLoopTTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Moq; + +namespace UnitTests.ConsoleDrivers.V2; +public class MainLoopTTests +{ + [Fact] + public void MainLoopT_NotInitialized_Throws() + { + var m = new MainLoop<int> (); + + Assert.Throws<NotInitializedException> (() => m.TimedEvents); + Assert.Throws<NotInitializedException> (() => m.InputBuffer); + Assert.Throws<NotInitializedException> (() => m.InputProcessor); + Assert.Throws<NotInitializedException> (() => m.Out); + Assert.Throws<NotInitializedException> (() => m.AnsiRequestScheduler); + Assert.Throws<NotInitializedException> (() => m.WindowSizeMonitor); + + m.Initialize (new TimedEvents (), + new ConcurrentQueue<int> (), + Mock.Of <IInputProcessor>(), + Mock.Of<IConsoleOutput>()); + + Assert.NotNull (m.TimedEvents); + Assert.NotNull (m.InputBuffer); + Assert.NotNull (m.InputProcessor); + Assert.NotNull (m.Out); + Assert.NotNull (m.AnsiRequestScheduler); + Assert.NotNull (m.WindowSizeMonitor); + } +} diff --git a/UnitTests/ConsoleDrivers/V2/MouseInterpreterTests.cs b/UnitTests/ConsoleDrivers/V2/MouseInterpreterTests.cs new file mode 100644 index 0000000000..50e2ac4c29 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/MouseInterpreterTests.cs @@ -0,0 +1,155 @@ +using Moq; + +namespace UnitTests.ConsoleDrivers.V2; +public class MouseInterpreterTests +{ + [Theory] + [MemberData (nameof (SequenceTests))] + public void TestMouseEventSequences_InterpretedOnlyAsFlag (List<MouseEventArgs> events, params MouseFlags?[] expected) + { + // Arrange: Mock dependencies and set up the interpreter + var interpreter = new MouseInterpreter (null); + + // Act and Assert + for (int i = 0; i < events.Count; i++) + { + var results = interpreter.Process (events [i]).ToArray(); + + // Raw input event should be there + Assert.Equal (events [i].Flags, results [0].Flags); + + // also any expected should be there + if (expected [i] != null) + { + Assert.Equal (expected [i], results [1].Flags); + } + else + { + Assert.Single (results); + } + } + } + + public static IEnumerable<object []> SequenceTests () + { + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button1Pressed }, + new() + }, + null, + MouseFlags.Button1Clicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button1Pressed }, + new(), + new() { Flags = MouseFlags.Button1Pressed }, + new() + }, + null, + MouseFlags.Button1Clicked, + null, + MouseFlags.Button1DoubleClicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button1Pressed }, + new(), + new() { Flags = MouseFlags.Button1Pressed }, + new(), + new() { Flags = MouseFlags.Button1Pressed }, + new() + }, + null, + MouseFlags.Button1Clicked, + null, + MouseFlags.Button1DoubleClicked, + null, + MouseFlags.Button1TripleClicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button2Pressed }, + new(), + new() { Flags = MouseFlags.Button2Pressed }, + new(), + new() { Flags = MouseFlags.Button2Pressed }, + new() + }, + null, + MouseFlags.Button2Clicked, + null, + MouseFlags.Button2DoubleClicked, + null, + MouseFlags.Button2TripleClicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button3Pressed }, + new(), + new() { Flags = MouseFlags.Button3Pressed }, + new(), + new() { Flags = MouseFlags.Button3Pressed }, + new() + }, + null, + MouseFlags.Button3Clicked, + null, + MouseFlags.Button3DoubleClicked, + null, + MouseFlags.Button3TripleClicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button4Pressed }, + new(), + new() { Flags = MouseFlags.Button4Pressed }, + new(), + new() { Flags = MouseFlags.Button4Pressed }, + new() + }, + null, + MouseFlags.Button4Clicked, + null, + MouseFlags.Button4DoubleClicked, + null, + MouseFlags.Button4TripleClicked + }; + + yield return new object [] + { + new List<MouseEventArgs> + { + new() { Flags = MouseFlags.Button1Pressed ,Position = new Point (10,11)}, + new(){Position = new Point (10,11)}, + + // Clicking the line below means no double click because it's a different location + new() { Flags = MouseFlags.Button1Pressed,Position = new Point (10,12) }, + new(){Position = new Point (10,12)} + }, + null, + MouseFlags.Button1Clicked, + null, + MouseFlags.Button1Clicked //release is click because new position + }; + } + +} diff --git a/UnitTests/ConsoleDrivers/V2/NetInputProcessorTests.cs b/UnitTests/ConsoleDrivers/V2/NetInputProcessorTests.cs new file mode 100644 index 0000000000..ec7a4fff60 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/NetInputProcessorTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace UnitTests.ConsoleDrivers.V2; +public class NetInputProcessorTests +{ + public static IEnumerable<object []> GetConsoleKeyInfoToKeyTestCases_Rune () + { + yield return new object [] { new ConsoleKeyInfo ('C', ConsoleKey.None, false, false, false), new Rune('C') }; + yield return new object [] { new ConsoleKeyInfo ('\\', ConsoleKey.Oem5, false, false, false), new Rune ('\\') }; + yield return new object [] { new ConsoleKeyInfo ('+', ConsoleKey.OemPlus, true, false, false), new Rune ('+') }; + yield return new object [] { new ConsoleKeyInfo ('=', ConsoleKey.OemPlus, false, false, false), new Rune ('=') }; + yield return new object [] { new ConsoleKeyInfo ('_', ConsoleKey.OemMinus, true, false, false), new Rune ('_') }; + yield return new object [] { new ConsoleKeyInfo ('-', ConsoleKey.OemMinus, false, false, false), new Rune ('-') }; + yield return new object [] { new ConsoleKeyInfo (')', ConsoleKey.None, false, false, false), new Rune (')') }; + yield return new object [] { new ConsoleKeyInfo ('0', ConsoleKey.None, false, false, false), new Rune ('0') }; + yield return new object [] { new ConsoleKeyInfo ('(', ConsoleKey.None, false, false, false), new Rune ('(') }; + yield return new object [] { new ConsoleKeyInfo ('9', ConsoleKey.None, false, false, false), new Rune ('9') }; + yield return new object [] { new ConsoleKeyInfo ('*', ConsoleKey.None, false, false, false), new Rune ('*') }; + yield return new object [] { new ConsoleKeyInfo ('8', ConsoleKey.None, false, false, false), new Rune ('8') }; + yield return new object [] { new ConsoleKeyInfo ('&', ConsoleKey.None, false, false, false), new Rune ('&') }; + yield return new object [] { new ConsoleKeyInfo ('7', ConsoleKey.None, false, false, false), new Rune ('7') }; + yield return new object [] { new ConsoleKeyInfo ('^', ConsoleKey.None, false, false, false), new Rune ('^') }; + yield return new object [] { new ConsoleKeyInfo ('6', ConsoleKey.None, false, false, false), new Rune ('6') }; + yield return new object [] { new ConsoleKeyInfo ('%', ConsoleKey.None, false, false, false), new Rune ('%') }; + yield return new object [] { new ConsoleKeyInfo ('5', ConsoleKey.None, false, false, false), new Rune ('5') }; + yield return new object [] { new ConsoleKeyInfo ('$', ConsoleKey.None, false, false, false), new Rune ('$') }; + yield return new object [] { new ConsoleKeyInfo ('4', ConsoleKey.None, false, false, false), new Rune ('4') }; + yield return new object [] { new ConsoleKeyInfo ('#', ConsoleKey.None, false, false, false), new Rune ('#') }; + yield return new object [] { new ConsoleKeyInfo ('@', ConsoleKey.None, false, false, false), new Rune ('@') }; + yield return new object [] { new ConsoleKeyInfo ('2', ConsoleKey.None, false, false, false), new Rune ('2') }; + yield return new object [] { new ConsoleKeyInfo ('!', ConsoleKey.None, false, false, false), new Rune ('!') }; + yield return new object [] { new ConsoleKeyInfo ('1', ConsoleKey.None, false, false, false), new Rune ('1') }; + yield return new object [] { new ConsoleKeyInfo ('\t', ConsoleKey.None, false, false, false), new Rune ('\t') }; + yield return new object [] { new ConsoleKeyInfo ('}', ConsoleKey.Oem6, true, false, false), new Rune ('}') }; + yield return new object [] { new ConsoleKeyInfo (']', ConsoleKey.Oem6, false, false, false), new Rune (']') }; + yield return new object [] { new ConsoleKeyInfo ('{', ConsoleKey.Oem4, true, false, false), new Rune ('{') }; + yield return new object [] { new ConsoleKeyInfo ('[', ConsoleKey.Oem4, false, false, false), new Rune ('[') }; + yield return new object [] { new ConsoleKeyInfo ('\"', ConsoleKey.Oem7, true, false, false), new Rune ('\"') }; + yield return new object [] { new ConsoleKeyInfo ('\'', ConsoleKey.Oem7, false, false, false), new Rune ('\'') }; + yield return new object [] { new ConsoleKeyInfo (':', ConsoleKey.Oem1, true, false, false), new Rune (':') }; + yield return new object [] { new ConsoleKeyInfo (';', ConsoleKey.Oem1, false, false, false), new Rune (';') }; + yield return new object [] { new ConsoleKeyInfo ('?', ConsoleKey.Oem2, true, false, false), new Rune ('?') }; + yield return new object [] { new ConsoleKeyInfo ('/', ConsoleKey.Oem2, false, false, false), new Rune ('/') }; + yield return new object [] { new ConsoleKeyInfo ('>', ConsoleKey.OemPeriod, true, false, false), new Rune ('>') }; + yield return new object [] { new ConsoleKeyInfo ('.', ConsoleKey.OemPeriod, false, false, false), new Rune ('.') }; + yield return new object [] { new ConsoleKeyInfo ('<', ConsoleKey.OemComma, true, false, false), new Rune ('<') }; + yield return new object [] { new ConsoleKeyInfo (',', ConsoleKey.OemComma, false, false, false), new Rune (',') }; + yield return new object [] { new ConsoleKeyInfo ('w', ConsoleKey.None, false, false, false), new Rune ('w') }; + yield return new object [] { new ConsoleKeyInfo ('e', ConsoleKey.None, false, false, false), new Rune ('e') }; + yield return new object [] { new ConsoleKeyInfo ('a', ConsoleKey.None, false, false, false), new Rune ('a') }; + yield return new object [] { new ConsoleKeyInfo ('s', ConsoleKey.None, false, false, false), new Rune ('s') }; + } + + [Theory] + [MemberData (nameof (GetConsoleKeyInfoToKeyTestCases_Rune))] + public void ConsoleKeyInfoToKey_ValidInput_AsRune (ConsoleKeyInfo input, Rune expected) + { + var converter = new NetKeyConverter (); + + // Act + var result = converter.ToKey (input); + + // Assert + Assert.Equal (expected, result.AsRune); + } + + public static IEnumerable<object []> GetConsoleKeyInfoToKeyTestCases_Key () + { + yield return new object [] { new ConsoleKeyInfo ('\t', ConsoleKey.None, false, false, false), Key.Tab}; + yield return new object [] { new ConsoleKeyInfo ('\u001B', ConsoleKey.None, false, false, false), Key.Esc }; + yield return new object [] { new ConsoleKeyInfo ('\u007f', ConsoleKey.None, false, false, false), Key.Backspace }; + + // TODO: Terminal.Gui does not have a Key for this mapped + // TODO: null and default(Key) are both not same as Null. Why user has to do (Key)0 to get a null key?! + yield return new object [] { new ConsoleKeyInfo ('\0', ConsoleKey.LeftWindows, false, false, false), (Key)0 }; + + } + + + + [Theory] + [MemberData (nameof (GetConsoleKeyInfoToKeyTestCases_Key))] + public void ConsoleKeyInfoToKey_ValidInput_AsKey (ConsoleKeyInfo input, Key expected) + { + var converter = new NetKeyConverter (); + // Act + var result = converter.ToKey (input); + + // Assert + Assert.Equal (expected, result); + } + + [Fact] + public void Test_ProcessQueue_CapitalHLowerE () + { + var queue = new ConcurrentQueue<ConsoleKeyInfo> (); + + queue.Enqueue (new ConsoleKeyInfo ('H', ConsoleKey.None, true, false, false)); + queue.Enqueue (new ConsoleKeyInfo ('e', ConsoleKey.None, false, false, false)); + + var processor = new NetInputProcessor (queue); + + List<Key> ups = new List<Key> (); + List<Key> downs = new List<Key> (); + + processor.KeyUp += (s, e) => { ups.Add (e); }; + processor.KeyDown += (s, e) => { downs.Add (e); }; + + Assert.Empty (ups); + Assert.Empty (downs); + + processor.ProcessQueue (); + + Assert.Equal (Key.H.WithShift, ups [0]); + Assert.Equal (Key.H.WithShift, downs [0]); + Assert.Equal (Key.E, ups [1]); + Assert.Equal (Key.E, downs [1]); + } +} diff --git a/UnitTests/ConsoleDrivers/V2/WindowSizeMonitorTests.cs b/UnitTests/ConsoleDrivers/V2/WindowSizeMonitorTests.cs new file mode 100644 index 0000000000..8b7c7a7b64 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/WindowSizeMonitorTests.cs @@ -0,0 +1,76 @@ +using Moq; + +namespace UnitTests.ConsoleDrivers.V2; +public class WindowSizeMonitorTests +{ + [Fact] + public void TestWindowSizeMonitor_RaisesEventWhenChanges () + { + var consoleOutput = new Mock<IConsoleOutput> (); + + var queue = new Queue<Size>(new []{ + new Size (30, 20), + new Size (20, 20) + + }); + + consoleOutput.Setup (m => m.GetWindowSize ()) + .Returns (queue.Dequeue); + + var outputBuffer = Mock.Of<IOutputBuffer> (); + + var monitor = new WindowSizeMonitor (consoleOutput.Object, outputBuffer); + + var result = new List<SizeChangedEventArgs> (); + monitor.SizeChanging += (s, e) => { result.Add (e);}; + + Assert.Empty (result); + monitor.Poll (); + + Assert.Single (result); + Assert.Equal (new Size (30,20),result [0].Size); + + monitor.Poll (); + + Assert.Equal (2,result.Count); + Assert.Equal (new Size (30, 20), result [0].Size); + Assert.Equal (new Size (20, 20), result [1].Size); + } + + [Fact] + public void TestWindowSizeMonitor_DoesNotRaiseEventWhen_NoChanges () + { + var consoleOutput = new Mock<IConsoleOutput> (); + + var queue = new Queue<Size> (new []{ + new Size (30, 20), + new Size (30, 20), + }); + + consoleOutput.Setup (m => m.GetWindowSize ()) + .Returns (queue.Dequeue); + + var outputBuffer = Mock.Of<IOutputBuffer> (); + + var monitor = new WindowSizeMonitor (consoleOutput.Object, outputBuffer); + + var result = new List<SizeChangedEventArgs> (); + monitor.SizeChanging += (s, e) => { result.Add (e); }; + + // First poll always raises event because going from unknown size i.e. 0,0 + Assert.Empty (result); + monitor.Poll (); + + Assert.Single (result); + Assert.Equal (new Size (30, 20), result [0].Size); + + // No change + monitor.Poll (); + + Assert.Single (result); + Assert.Equal (new Size (30, 20), result [0].Size); + } + + + +} diff --git a/UnitTests/ConsoleDrivers/V2/WindowsInputProcessorTests.cs b/UnitTests/ConsoleDrivers/V2/WindowsInputProcessorTests.cs new file mode 100644 index 0000000000..7c9fc14429 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/WindowsInputProcessorTests.cs @@ -0,0 +1,305 @@ +using System.Collections.Concurrent; +using Terminal.Gui.ConsoleDrivers; +using InputRecord = Terminal.Gui.WindowsConsole.InputRecord; +using ButtonState = Terminal.Gui.WindowsConsole.ButtonState; +using MouseEventRecord = Terminal.Gui.WindowsConsole.MouseEventRecord; + +namespace UnitTests.ConsoleDrivers.V2; +public class WindowsInputProcessorTests +{ + + [Fact] + public void Test_ProcessQueue_CapitalHLowerE () + { + var queue = new ConcurrentQueue<InputRecord> (); + + queue.Enqueue (new InputRecord() + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new WindowsConsole.KeyEventRecord () + { + bKeyDown = true, + UnicodeChar = 'H', + dwControlKeyState = WindowsConsole.ControlKeyState.CapslockOn, + wVirtualKeyCode = (ConsoleKeyMapping.VK)72, + wVirtualScanCode = 35 + } + }); + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new WindowsConsole.KeyEventRecord () + { + bKeyDown = false, + UnicodeChar = 'H', + dwControlKeyState = WindowsConsole.ControlKeyState.CapslockOn, + wVirtualKeyCode = (ConsoleKeyMapping.VK)72, + wVirtualScanCode = 35 + } + }); + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new WindowsConsole.KeyEventRecord () + { + bKeyDown = true, + UnicodeChar = 'i', + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + wVirtualKeyCode = (ConsoleKeyMapping.VK)73, + wVirtualScanCode = 23 + } + }); + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new WindowsConsole.KeyEventRecord () + { + bKeyDown = false, + UnicodeChar = 'i', + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + wVirtualKeyCode = (ConsoleKeyMapping.VK)73, + wVirtualScanCode = 23 + } + }); + + var processor = new WindowsInputProcessor (queue); + + List<Key> ups = new List<Key> (); + List<Key> downs = new List<Key> (); + + processor.KeyUp += (s, e) => { ups.Add (e); }; + processor.KeyDown += (s, e) => { downs.Add (e); }; + + Assert.Empty (ups); + Assert.Empty (downs); + + processor.ProcessQueue (); + + Assert.Equal (Key.H.WithShift, ups [0]); + Assert.Equal (Key.H.WithShift, downs [0]); + Assert.Equal (Key.I, ups [1]); + Assert.Equal (Key.I, downs [1]); + } + + + [Fact] + public void Test_ProcessQueue_Mouse_Move () + { + var queue = new ConcurrentQueue<InputRecord> (); + + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Mouse, + MouseEvent = new WindowsConsole.MouseEventRecord + { + MousePosition = new WindowsConsole.Coord(32,31), + ButtonState = WindowsConsole.ButtonState.NoButtonPressed, + ControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + EventFlags = WindowsConsole.EventFlags.MouseMoved + } + }); + + var processor = new WindowsInputProcessor (queue); + + List<MouseEventArgs> mouseEvents = new List<MouseEventArgs> (); + + processor.MouseEvent += (s, e) => { mouseEvents.Add (e); }; + + Assert.Empty (mouseEvents); + + processor.ProcessQueue (); + + var s = Assert.Single (mouseEvents); + Assert.Equal (s.Flags,MouseFlags.ReportMousePosition); + Assert.Equal (s.ScreenPosition,new Point (32,31)); + } + + [Theory] + [InlineData(WindowsConsole.ButtonState.Button1Pressed,MouseFlags.Button1Pressed)] + [InlineData (WindowsConsole.ButtonState.Button2Pressed, MouseFlags.Button2Pressed)] + [InlineData (WindowsConsole.ButtonState.Button3Pressed, MouseFlags.Button3Pressed)] + [InlineData (WindowsConsole.ButtonState.Button4Pressed, MouseFlags.Button4Pressed)] + internal void Test_ProcessQueue_Mouse_Pressed (WindowsConsole.ButtonState state,MouseFlags expectedFlag ) + { + var queue = new ConcurrentQueue<InputRecord> (); + + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Mouse, + MouseEvent = new WindowsConsole.MouseEventRecord + { + MousePosition = new WindowsConsole.Coord (32, 31), + ButtonState = state, + ControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + EventFlags = WindowsConsole.EventFlags.MouseMoved + } + }); + + var processor = new WindowsInputProcessor (queue); + + List<MouseEventArgs> mouseEvents = new List<MouseEventArgs> (); + + processor.MouseEvent += (s, e) => { mouseEvents.Add (e); }; + + Assert.Empty (mouseEvents); + + processor.ProcessQueue (); + + var s = Assert.Single (mouseEvents); + Assert.Equal (s.Flags, MouseFlags.ReportMousePosition | expectedFlag); + Assert.Equal (s.ScreenPosition, new Point (32, 31)); + } + + + [Theory] + [InlineData (100, MouseFlags.WheeledUp)] + [InlineData ( -100, MouseFlags.WheeledDown)] + internal void Test_ProcessQueue_Mouse_Wheel (int wheelValue, MouseFlags expectedFlag) + { + var queue = new ConcurrentQueue<InputRecord> (); + + queue.Enqueue (new InputRecord () + { + EventType = WindowsConsole.EventType.Mouse, + MouseEvent = new WindowsConsole.MouseEventRecord + { + MousePosition = new WindowsConsole.Coord (32, 31), + ButtonState = (WindowsConsole.ButtonState)wheelValue, + ControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed, + EventFlags = WindowsConsole.EventFlags.MouseWheeled + } + }); + + var processor = new WindowsInputProcessor (queue); + + List<MouseEventArgs> mouseEvents = new List<MouseEventArgs> (); + + processor.MouseEvent += (s, e) => { mouseEvents.Add (e); }; + + Assert.Empty (mouseEvents); + + processor.ProcessQueue (); + + var s = Assert.Single (mouseEvents); + Assert.Equal (s.Flags,expectedFlag); + Assert.Equal (s.ScreenPosition, new Point (32, 31)); + } + + public static IEnumerable<object []> MouseFlagTestData () + { + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button1Pressed, MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button1Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition | MouseFlags.ReportMousePosition) + } + }; + + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create ( ButtonState.Button2Pressed, MouseFlags.Button2Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button2Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition | MouseFlags.ReportMousePosition) + } + }; + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button3Pressed, MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button3Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition | MouseFlags.ReportMousePosition) + } + }; + + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button4Pressed, MouseFlags.Button4Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button4Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition | MouseFlags.ReportMousePosition) + } + }; + + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.RightmostButtonPressed, MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button3Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition | MouseFlags.ReportMousePosition) + } + }; + + // Tests for holding down 2 buttons at once and releasing them one after the other + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button1Pressed | ButtonState.Button2Pressed, MouseFlags.Button1Pressed | MouseFlags.Button2Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.Button1Pressed, MouseFlags.Button1Pressed | MouseFlags.Button2Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button1Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition) + } + }; + + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button3Pressed | ButtonState.Button4Pressed, MouseFlags.Button3Pressed | MouseFlags.Button4Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.Button3Pressed, MouseFlags.Button3Pressed | MouseFlags.Button4Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button3Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition) + } + }; + + // Test for holding down 2 buttons at once and releasing them simultaneously + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button1Pressed | ButtonState.Button2Pressed, MouseFlags.Button1Pressed | MouseFlags.Button2Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button1Released | MouseFlags.Button2Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition) + } + }; + + // Test that rightmost and button 3 are the same button so 2 states is still only 1 flag + yield return new object [] + { + new Tuple<ButtonState, MouseFlags>[] + { + Tuple.Create(ButtonState.Button3Pressed | ButtonState.RightmostButtonPressed, MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition), + // Can swap between without raising the released + Tuple.Create(ButtonState.Button3Pressed, MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.RightmostButtonPressed, MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition), + + // Now with neither we get released + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.Button3Released | MouseFlags.ReportMousePosition), + Tuple.Create(ButtonState.NoButtonPressed, MouseFlags.ReportMousePosition) + } + }; + } + + [Theory] + [MemberData (nameof (MouseFlagTestData))] + internal void MouseFlags_Should_Map_Correctly (Tuple<ButtonState, MouseFlags>[] inputOutputPairs) + { + var processor = new WindowsInputProcessor (new ()); + + foreach (var pair in inputOutputPairs) + { + var mockEvent = new MouseEventRecord { ButtonState = pair.Item1 }; + var result = processor.ToDriverMouse (mockEvent); + + Assert.Equal (pair.Item2, result.Flags); + } + } +} + diff --git a/UnitTests/UICatalog/ScenarioTests.cs b/UnitTests/UICatalog/ScenarioTests.cs index aeac22357a..09ba8b7543 100644 --- a/UnitTests/UICatalog/ScenarioTests.cs +++ b/UnitTests/UICatalog/ScenarioTests.cs @@ -222,15 +222,20 @@ void OnApplicationOnInitializedChanged (object s, EventArgs<bool> a) initialized = true; Application.Iteration += OnApplicationOnIteration; Application.Driver!.ClearedContents += (sender, args) => clearedContentCount++; - Application.Driver!.Refreshed += (sender, args) => - { - refreshedCount++; - - if (args.CurrentValue) - { - updatedCount++; - } - }; + + if (Application.Driver is ConsoleDriver cd) + { + cd!.Refreshed += (sender, args) => + { + refreshedCount++; + + if (args.CurrentValue) + { + updatedCount++; + } + }; + } + Application.NotifyNewRunState += OnApplicationNotifyNewRunState; stopwatch = Stopwatch.StartNew (); @@ -800,7 +805,7 @@ public void Run_Generic () if (token == null) { // Timeout only must start at first iteration - token = Application.MainLoop.AddTimeout (TimeSpan.FromMilliseconds (ms), abortCallback); + token = Application.AddTimeout (TimeSpan.FromMilliseconds (ms), abortCallback); } iterations++; diff --git a/UnitTests/View/Draw/AllViewsDrawTests.cs b/UnitTests/View/Draw/AllViewsDrawTests.cs index fa865068bf..64d6c543eb 100644 --- a/UnitTests/View/Draw/AllViewsDrawTests.cs +++ b/UnitTests/View/Draw/AllViewsDrawTests.cs @@ -5,10 +5,13 @@ namespace Terminal.Gui.LayoutTests; public class AllViewsDrawTests (ITestOutputHelper _output) : TestsAllViews { [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_Draw_Does_Not_Layout (Type viewType) { Application.ResetState (true); + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); var view = (View)CreateInstanceIfNotGeneric (viewType); diff --git a/UnitTests/View/Keyboard/KeyboardEventTests.cs b/UnitTests/View/Keyboard/KeyboardEventTests.cs index fa842d50fc..7cbbcaa8fa 100644 --- a/UnitTests/View/Keyboard/KeyboardEventTests.cs +++ b/UnitTests/View/Keyboard/KeyboardEventTests.cs @@ -11,6 +11,7 @@ public class KeyboardEventTests (ITestOutputHelper output) : TestsAllViews /// events: KeyDown and KeyDownNotHandled. Note that KeyUp is independent. /// </summary> [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_NewKeyDownEvent_All_EventsFire (Type viewType) { @@ -53,6 +54,7 @@ public void AllViews_NewKeyDownEvent_All_EventsFire (Type viewType) /// KeyUp /// </summary> [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_NewKeyUpEvent_All_EventsFire (Type viewType) { diff --git a/UnitTests/View/Layout/LayoutTests.cs b/UnitTests/View/Layout/LayoutTests.cs index d1cde695d7..1b41105532 100644 --- a/UnitTests/View/Layout/LayoutTests.cs +++ b/UnitTests/View/Layout/LayoutTests.cs @@ -5,9 +5,14 @@ namespace Terminal.Gui.LayoutTests; public class LayoutTests (ITestOutputHelper _output) : TestsAllViews { [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_Layout_Does_Not_Draw (Type viewType) { + + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); + var view = (View)CreateInstanceIfNotGeneric (viewType); if (view == null) diff --git a/UnitTests/View/Mouse/MouseTests.cs b/UnitTests/View/Mouse/MouseTests.cs index ab23aead12..9c91735ac4 100644 --- a/UnitTests/View/Mouse/MouseTests.cs +++ b/UnitTests/View/Mouse/MouseTests.cs @@ -154,6 +154,7 @@ public void NewMouseEvent_Invokes_MouseEvent_Properly () } [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_NewMouseEvent_Enabled_False_Does_Not_Set_Handled (Type viewType) { @@ -173,6 +174,7 @@ public void AllViews_NewMouseEvent_Enabled_False_Does_Not_Set_Handled (Type view } [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_NewMouseEvent_Clicked_Enabled_False_Does_Not_Set_Handled (Type viewType) { diff --git a/UnitTests/Views/AllViewsTests.cs b/UnitTests/Views/AllViewsTests.cs index bc20443618..456c2be0bd 100644 --- a/UnitTests/Views/AllViewsTests.cs +++ b/UnitTests/Views/AllViewsTests.cs @@ -12,8 +12,12 @@ public class AllViewsTests (ITestOutputHelper output) : TestsAllViews [Theory] [MemberData (nameof (AllViewTypes))] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts public void AllViews_Center_Properly (Type viewType) { + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); + var view = (View)CreateInstanceIfNotGeneric (viewType); // See https://github.com/gui-cs/Terminal.Gui/issues/3156 @@ -62,6 +66,7 @@ public void AllViews_Center_Properly (Type viewType) [Theory] [MemberData (nameof (AllViewTypes))] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts public void AllViews_Tests_All_Constructors (Type viewType) { Assert.True (Test_All_Constructors_Of_Type (viewType)); @@ -97,8 +102,12 @@ public bool Test_All_Constructors_Of_Type (Type type) [Theory] [MemberData (nameof (AllViewTypes))] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts public void AllViews_Command_Select_Raises_Selecting (Type viewType) { + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); + var view = (View)CreateInstanceIfNotGeneric (viewType); if (view == null) @@ -131,9 +140,13 @@ public void AllViews_Command_Select_Raises_Selecting (Type viewType) } [Theory] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts [MemberData (nameof (AllViewTypes))] public void AllViews_Command_Accept_Raises_Accepted (Type viewType) { + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); + var view = (View)CreateInstanceIfNotGeneric (viewType); if (view == null) @@ -168,8 +181,12 @@ public void AllViews_Command_Accept_Raises_Accepted (Type viewType) [Theory] [MemberData (nameof (AllViewTypes))] + [SetupFakeDriver] // Required for spinner view that wants to register timeouts public void AllViews_Command_HotKey_Raises_HandlingHotKey (Type viewType) { + // Required for spinner view that wants to register timeouts + Application.MainLoop = new MainLoop (new FakeMainLoop (Application.Driver)); + var view = (View)CreateInstanceIfNotGeneric (viewType); if (view == null) diff --git a/UnitTests/Views/SpinnerViewTests.cs b/UnitTests/Views/SpinnerViewTests.cs index b8e62f8281..0af6f0b50a 100644 --- a/UnitTests/Views/SpinnerViewTests.cs +++ b/UnitTests/Views/SpinnerViewTests.cs @@ -15,33 +15,33 @@ public void TestSpinnerView_AutoSpin (bool callStop) { SpinnerView view = GetSpinnerView (); - Assert.Empty (Application.MainLoop._timeouts); + Assert.Empty (Application.MainLoop.TimedEvents.Timeouts); view.AutoSpin = true; - Assert.NotEmpty (Application.MainLoop._timeouts); + Assert.NotEmpty (Application.MainLoop.TimedEvents.Timeouts); Assert.True (view.AutoSpin); //More calls to AutoSpin do not add more timeouts - Assert.Single (Application.MainLoop._timeouts); + Assert.Single (Application.MainLoop.TimedEvents.Timeouts); view.AutoSpin = true; view.AutoSpin = true; view.AutoSpin = true; Assert.True (view.AutoSpin); - Assert.Single (Application.MainLoop._timeouts); + Assert.Single (Application.MainLoop.TimedEvents.Timeouts); if (callStop) { view.AutoSpin = false; - Assert.Empty (Application.MainLoop._timeouts); + Assert.Empty (Application.MainLoop.TimedEvents.Timeouts); Assert.False (view.AutoSpin); } else { - Assert.NotEmpty (Application.MainLoop._timeouts); + Assert.NotEmpty (Application.MainLoop.TimedEvents.Timeouts); } // Dispose clears timeout view.Dispose (); - Assert.Empty (Application.MainLoop._timeouts); + Assert.Empty (Application.MainLoop.TimedEvents.Timeouts); Application.Top.Dispose (); } diff --git a/docfx/docs/ansiparser.md b/docfx/docs/ansiparser.md new file mode 100644 index 0000000000..d18bbe8882 --- /dev/null +++ b/docfx/docs/ansiparser.md @@ -0,0 +1,50 @@ +# AnsiResponseParser + +## Background +Terminals send input to the running process as a stream of characters. In addition to regular characters ('a','b','c' etc), the terminal needs to be able to communicate more advanced concepts ('Alt+x', 'Mouse Moved', 'Terminal resized' etc). This is done through the use of 'terminal sequences'. + +All terminal sequences start with Esc (`'\x1B'`) and are then followed by specific characters per the event to which they correspond. For example: + +| Input Sequence | Meaning | +|----------------|-------------------------------------------| +| `<Esc>[A` | Up Arrow Key | +| `<Esc>[B` | Down Arrow Key | +| `<Esc>[5~` | Page Up Key | +| `<Esc>[6~` | Page Down Key | + +Most sequences begin with what is called a `CSI` which is just `\x1B[` (`<Esc>[`). But some terminals send older sequences such as: + +| Input Sequence | Meaning | +|----------------|-------------------------------------------| +| `<Esc>OR` | F3 (SS3 Pattern) | +| `<Esc>OD` | Left Arrow Key (SS3 Pattern) | +| `<Esc>g` | Alt+G (Escape as alt) | +| `<Esc>O` | Alt+Shift+O (Escape as alt)| + +When using the windows driver, this is mostly already dealt with automatically by the relevant native APIs (e.g. `ReadConsoleInputW` from kernel32). In contrast the net driver operates in 'raw mode' and processes input almost exclusively using these sequences. + +Regardless of the driver used, some escape codes will always be processed e.g. responses to Device Attributes Requests etc. + +## Role of AnsiResponseParser + +This class is responsible for filtering the input stream to distinguish between regular user key presses and terminal sequences. + +## Timing +Timing is a critical component of interpreting the input stream. For example if the stream serves the escape (Esc), the parser must decide whether it's a standalone keypress or the start of a sequence. Similarly seeing the sequence `<Esc>O` could be Alt+Upper Case O, or the beginning of an SS3 pattern for F3 (were R to follow). + +Because it is such a critical component, it is abstracted away from the parser itself. Instead the host class (e.g. InputProcessor) must decide when a suitable time has elapsed, after which the `Release` method should be invoked which will forcibly resolve any waiting state. + +This can be controlled through the `_escTimeout` field. This approach is consistent with other terminal interfaces e.g. bash. + +## State and Held + +The parser has 3 states: + +| State | Meaning | +|----------------|-------------------------------------------| +| `Normal` | We are encountering regular keys and letting them pass through unprocessed| +| `ExpectingEscapeSequence` | We encountered an `Esc` and are holding it to see if a sequence follows | +| `InResponse` | The `Esc` was followed by more keys that look like they will make a full terminal sequence (hold them all) | + +Extensive trace logging is built into the implementation, to allow for reproducing corner cases and/or rare environments. See the logging article for more details on how to set this up. + diff --git a/docfx/docs/logging.md b/docfx/docs/logging.md new file mode 100644 index 0000000000..6df97a0feb --- /dev/null +++ b/docfx/docs/logging.md @@ -0,0 +1,123 @@ +# Logging + +Logging has come to Terminal.Gui! You can now enable comprehensive logging of the internals of the libray. This can help diagnose issues with specific terminals, keyboard cultures and/or operating system specific issues. + +To enable file logging you should set the static property `Logging.Logger` to an instance of `Microsoft.Extensions.Logging.ILogger`. If your program already uses logging you can provide a shared instance or instance from Dependency Injection (DI). + +Alternatively you can create a new log to ensure only Terminal.Gui logs appear. + +Any logging framework will work (Serilog, NLog, Log4Net etc) but you should ensure you only log to File or UDP etc (i.e. not to console!). + +## Worked example with Serilog to file + +Here is an example of how to add logging of Terminal.Gui internals to your program using Serilog file log. + +Add the Serilog dependencies: + +``` + dotnet add package Serilog + dotnet add package Serilog.Sinks.File + dotnet add package Serilog.Extensions.Logging +``` + +Create a static helper function to create the logger and store in `Logging.Logger`: + +```csharp +Logging.Logger = CreateLogger(); + + + static ILogger CreateLogger() +{ + // Configure Serilog to write logs to a file + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() // Verbose includes Trace and Debug + .WriteTo.File("logs/logfile.txt", rollingInterval: RollingInterval.Day) + .CreateLogger(); + + // Create a logger factory compatible with Microsoft.Extensions.Logging + using var loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddSerilog(dispose: true) // Integrate Serilog with ILogger + .SetMinimumLevel(LogLevel.Trace); // Set minimum log level + }); + + // Get an ILogger instance + return loggerFactory.CreateLogger("Global Logger"); +} +``` + +This will create logs in your executables directory (e.g.`\bin\Debug\net8.0`). + +Example logs: +``` +2025-02-15 13:36:48.635 +00:00 [INF] Main Loop Coordinator booting... +2025-02-15 13:36:48.663 +00:00 [INF] Creating NetOutput +2025-02-15 13:36:48.668 +00:00 [INF] Creating NetInput +2025-02-15 13:36:48.671 +00:00 [INF] Main Loop Coordinator booting complete +2025-02-15 13:36:49.145 +00:00 [INF] Run 'MainWindow(){X=0,Y=0,Width=0,Height=0}' +2025-02-15 13:36:49.163 +00:00 [VRB] Mouse Interpreter raising ReportMousePosition +2025-02-15 13:36:49.165 +00:00 [VRB] AnsiResponseParser handled as mouse '[<35;50;23m' +2025-02-15 13:36:49.166 +00:00 [VRB] MainWindow triggered redraw (NeedsDraw=True NeedsLayout=True) +2025-02-15 13:36:49.167 +00:00 [INF] Console size changes from '{Width=0, Height=0}' to {Width=120, Height=30} +2025-02-15 13:36:49.859 +00:00 [VRB] AnsiResponseParser releasing ' +' +2025-02-15 13:36:49.867 +00:00 [VRB] MainWindow triggered redraw (NeedsDraw=True NeedsLayout=True) +2025-02-15 13:36:50.857 +00:00 [VRB] MainWindow triggered redraw (NeedsDraw=True NeedsLayout=True) +2025-02-15 13:36:51.417 +00:00 [VRB] MainWindow triggered redraw (NeedsDraw=True NeedsLayout=True) +2025-02-15 13:36:52.224 +00:00 [VRB] Mouse Interpreter raising ReportMousePosition +2025-02-15 13:36:52.226 +00:00 [VRB] AnsiResponseParser handled as mouse '[<35;51;23m' +2025-02-15 13:36:52.226 +00:00 [VRB] Mouse Interpreter raising ReportMousePosition +2025-02-15 13:36:52.226 +00:00 [VRB] AnsiResponseParser handled as mouse '[<35;52;23m' +2025-02-15 13:36:52.226 +00:00 [VRB] Mouse Interpreter raising ReportMousePosition +2025-02-15 13:36:52.226 +00:00 [VRB] AnsiResponseParser handled as mouse '[<35;53;23m' +... +2025-02-15 13:36:52.846 +00:00 [VRB] Mouse Interpreter raising ReportMousePosition +2025-02-15 13:36:52.846 +00:00 [VRB] AnsiResponseParser handled as mouse '[<35;112;4m' +2025-02-15 13:36:54.151 +00:00 [INF] RequestStop '' +2025-02-15 13:36:54.151 +00:00 [VRB] AnsiResponseParser handled as keyboard '[21~' +2025-02-15 13:36:54.225 +00:00 [INF] Input loop exited cleanly +``` + +## Metrics + +If you are finding that the UI is slow or unresponsive - or are just interested in performance metrics. You can see these by instaling the `dotnet-counter` tool and running it for your process. + +``` +dotnet tool install dotnet-counters --global +``` + +``` + dotnet-counters monitor -n YourProcessName --counters Terminal.Gui +``` + +Example output: + +``` +Press p to pause, r to resume, q to quit. + Status: Running + +Name Current Value +[Terminal.Gui] + Drain Input (ms) + Percentile + 50 0 + 95 0 + 99 0 + Invokes & Timers (ms) + Percentile + 50 0 + 95 0 + 99 0 + Iteration (ms) + Percentile + 50 0 + 95 1 + 99 1 + Redraws (Count) 9 +``` + +Metrics figures issues such as: + +- Your console constantly being refreshed (Redraws) +- You are blocking main thread with long running Invokes / Timeout callbacks (Invokes & Timers)