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) /// [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. /// - 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 (); /// /// Gets whether the application has been initialized with and not yet shutdown with . @@ -258,4 +257,12 @@ public static void Shutdown () /// Intended to support unit tests that need to know when the application has been initialized. /// public static event EventHandler>? InitializedChanged; + + /// + /// Raises the event. + /// + internal static void OnInitializedChanged (object sender, EventArgs 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 () /// The created object. The caller is responsible for disposing this object. [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public static Toplevel Run (Func? errorHandler = null, IConsoleDriver? driver = null) { return Run (errorHandler, driver); } + public static Toplevel Run (Func? errorHandler = null, IConsoleDriver? driver = null) => + ApplicationImpl.Instance.Run (errorHandler, driver); /// /// Runs the application by creating a -derived object of type T and calling @@ -331,20 +332,7 @@ internal static bool PositionCursor () [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static T Run (Func? 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 (errorHandler, driver); /// Runs the Application using the provided view. /// @@ -385,73 +373,7 @@ public static T Run (Func? errorHandler = null, IConsoleDriv /// rethrows when null). /// public static void Run (Toplevel view, Func? 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() cannot be called." - ); - } - } - else - { - // Init() has NOT been called. - throw new InvalidOperationException ( - "Init() has not been called. Only Run() or Run() 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); /// Adds a timeout to the application. /// @@ -459,36 +381,23 @@ public static void Run (Toplevel view, Func? 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 . /// - public static object? AddTimeout (TimeSpan time, Func callback) - { - return MainLoop?.AddTimeout (time, callback) ?? null; - } + public static object? AddTimeout (TimeSpan time, Func callback) => ApplicationImpl.Instance.AddTimeout (time, callback); /// Removes a previously scheduled timeout /// The token parameter is the value returned by . /// Returns - /// true + /// /// if the timeout is successfully removed; otherwise, - /// false + /// /// . /// This method also returns - /// 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); /// Runs on the thread that is processing events /// the action to be invoked on the main processing thread. - 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) /// The driver for the application /// The main loop. - internal static MainLoop? MainLoop { get; private set; } + internal static MainLoop? MainLoop { get; set; } /// /// Set to true to cause 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 to false. /// /// - 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 @@ + + + + + + hEI4FAgAqARIspQfBQo0gTGiACNL0AICESJKoggBSg8= + Application\Application.cs + + + + + + AABAAAAAAABCAAAAAAAAAAAAAAAAIgIAAAAAAAAAAAA= + Application\ApplicationNavigation.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Application\IterationEventArgs.cs + + + + + + CAAAIAAAASAAAQAQAAAAAIBADQAAEAAYIgIIwAAAAAI= + Application\MainLoop.cs + + + + + + + AAAAAgAAAAAAAAAAAEAAAAAACAAAAAAAAAAAAAAAAAA= + Application\MainLoopSyncContext.cs + + + + + + AAAAAAAAACACAgAAAAAAAAAAAAAAAAACQAAAAAAAAAA= + Application\RunState.cs + + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAA= + Application\RunStateEventArgs.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAQAA= + Application\Timeout.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAACAIAAAAAAAAAAAA= + Application\TimeoutEventArgs.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAQAACAACAAAI= + Application\ApplicationImpl.cs + + + + + + + AAAAAAAACAAAAAQAAAAABAAAAAAAEAAAAAAAAAAAAAA= + Application\MainLoop.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAACAAAAAAI= + Application\IApplication.cs + + + + \ 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 { /// Gets all cultures supported by the application without the invariant language. - public static List? SupportedCultures { get; private set; } + public static List? SupportedCultures { get; private set; } = GetSupportedCultures (); /// /// Gets a string representation of the Application as rendered by . @@ -224,5 +224,10 @@ internal static void ResetState (bool ignoreDisposed = false) SynchronizationContext.SetSynchronizationContext (null); } - // Only return true if the Current has changed. + + /// + /// Adds specified idle handler function to main iteration processing. The handler function will be called + /// once per iteration of the main loop after other events have been handled. + /// + public static void AddIdle (Func func) => ApplicationImpl.Instance.AddIdle (func); } 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; + +/// +/// Original Terminal.Gui implementation of core methods. +/// +public class ApplicationImpl : IApplication +{ + // Private static readonly Lazy instance of Application + private static Lazy _lazyInstance = new (() => new ApplicationImpl ()); + + /// + /// Gets the currently configured backend implementation of gateway methods. + /// Change to your own implementation by using (before init). + /// + public static IApplication Instance => _lazyInstance.Value; + + /// + /// 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 . + /// + /// + public static void ChangeInstance (IApplication newApplication) + { + _lazyInstance = new Lazy (newApplication); + } + + /// + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public virtual void Init (IConsoleDriver? driver = null, string? driverName = null) + { + Application.InternalInit (driver, driverName); + } + + /// + /// Runs the application by creating a object and calling + /// . + /// + /// + /// Calling first is not needed as this function will initialize the application. + /// + /// must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// + /// + /// The caller is responsible for disposing the object returned by this method. + /// + /// + /// The created object. The caller is responsible for disposing this object. + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public Toplevel Run (Func? errorHandler = null, IConsoleDriver? driver = null) { return Run (errorHandler, driver); } + + /// + /// Runs the application by creating a -derived object of type T and calling + /// . + /// + /// + /// Calling first is not needed as this function will initialize the application. + /// + /// must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// + /// + /// The caller is responsible for disposing the object returned by this method. + /// + /// + /// + /// + /// The to use. If not specified the default driver for the platform will + /// be used ( , , or ). Must be + /// if has already been called. + /// + /// The created T object. The caller is responsible for disposing this object. + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public virtual T Run (Func? 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; + } + + /// Runs the Application using the provided view. + /// + /// + /// This method is used to start processing events for the main application, but it is also used to run other + /// modal s such as boxes. + /// + /// + /// To make a stop execution, call + /// . + /// + /// + /// Calling is equivalent to calling + /// , followed by , and then calling + /// . + /// + /// + /// Alternatively, to have a program control the main loop and process events manually, call + /// to set things up manually and then repeatedly call + /// with the wait parameter set to false. By doing this the + /// method will only process any pending events, timers, idle handlers and then + /// return control immediately. + /// + /// When using or + /// + /// will be called automatically. + /// + /// + /// RELEASE builds only: When is any exceptions will be + /// rethrown. Otherwise, if will be called. If + /// returns the will resume; otherwise this method will + /// exit. + /// + /// + /// The to run as a modal. + /// + /// RELEASE builds only: Handler for any unhandled exceptions (resumes when returns true, + /// rethrows when null). + /// + public virtual void Run (Toplevel view, Func? 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() cannot be called." + ); + } + } + else + { + // Init() has NOT been called. + throw new InvalidOperationException ( + "Init() has not been called. Only Run() or Run() 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 + } + } + + /// Shutdown an application initialized with . + /// + /// Shutdown must be called for every call to or + /// to ensure all resources are cleaned + /// up (Disposed) + /// and terminal settings are restored. + /// + 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)); + } + } + + /// + 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); + } + + /// + public virtual void Invoke (Action action) + { + Application.MainLoop?.AddIdle ( + () => + { + action (); + + return false; + } + ); + } + + /// + public bool IsLegacy { get; protected set; } = true; + + /// + public virtual void AddIdle (Func 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); + + } + + /// + public virtual object AddTimeout (TimeSpan time, Func 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); + } + + /// + 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; + +/// +/// Interface for instances that provide backing functionality to static +/// gateway class . +/// +public interface IApplication +{ + /// Initializes a new instance of Application. + /// Call this method once per instance (or after has been called). + /// + /// This function loads the right for the platform, Creates a . and + /// assigns it to + /// + /// + /// must be called when the application is closing (typically after + /// has returned) to ensure resources are cleaned up and + /// terminal settings + /// restored. + /// + /// + /// The function combines + /// and + /// into a single + /// call. An application cam use without explicitly calling + /// . + /// + /// + /// The to use. If neither or + /// are specified the default driver for the platform will be used. + /// + /// + /// The short name (e.g. "net", "windows", "ansi", "fake", or "curses") of the + /// to use. If neither or are + /// specified the default driver for the platform will be used. + /// + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public void Init (IConsoleDriver? driver = null, string? driverName = null); + + + /// + /// Runs the application by creating a object and calling + /// . + /// + /// + /// Calling first is not needed as this function will initialize the application. + /// + /// must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// + /// + /// The caller is responsible for disposing the object returned by this method. + /// + /// + /// The created object. The caller is responsible for disposing this object. + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public Toplevel Run (Func? errorHandler = null, IConsoleDriver? driver = null); + + /// + /// Runs the application by creating a -derived object of type T and calling + /// . + /// + /// + /// Calling first is not needed as this function will initialize the application. + /// + /// must be called when the application is closing (typically after Run> has returned) to + /// ensure resources are cleaned up and terminal settings restored. + /// + /// + /// The caller is responsible for disposing the object returned by this method. + /// + /// + /// + /// + /// The to use. If not specified the default driver for the platform will + /// be used ( , , or ). Must be + /// if has already been called. + /// + /// The created T object. The caller is responsible for disposing this object. + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public T Run (Func? errorHandler = null, IConsoleDriver? driver = null) + where T : Toplevel, new (); + + /// Runs the Application using the provided view. + /// + /// + /// This method is used to start processing events for the main application, but it is also used to run other + /// modal s such as boxes. + /// + /// + /// To make a stop execution, call + /// . + /// + /// + /// Calling is equivalent to calling + /// , followed by , and then calling + /// . + /// + /// + /// Alternatively, to have a program control the main loop and process events manually, call + /// to set things up manually and then repeatedly call + /// with the wait parameter set to false. By doing this the + /// method will only process any pending events, timers, idle handlers and then + /// return control immediately. + /// + /// When using or + /// + /// will be called automatically. + /// + /// + /// RELEASE builds only: When is any exceptions will be + /// rethrown. Otherwise, if will be called. If + /// returns the will resume; otherwise this method will + /// exit. + /// + /// + /// The to run as a modal. + /// + /// RELEASE builds only: Handler for any unhandled exceptions (resumes when returns true, + /// rethrows when null). + /// + public void Run (Toplevel view, Func? errorHandler = null); + + /// Shutdown an application initialized with . + /// + /// Shutdown must be called for every call to or + /// to ensure all resources are cleaned + /// up (Disposed) + /// and terminal settings are restored. + /// + public void Shutdown (); + + /// Stops the provided , causing or the if provided. + /// The to stop. + /// + /// This will cause to return. + /// + /// Calling is equivalent to setting the + /// property on the currently running to false. + /// + /// + void RequestStop (Toplevel? top); + + /// Runs on the main UI loop thread + /// the action to be invoked on the main processing thread. + void Invoke (Action action); + + /// + /// if implementation is 'old'. if implementation + /// is cutting edge. + /// + bool IsLegacy { get; } + + /// + /// 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. + /// + void AddIdle (Func func); + + /// Adds a timeout to the application. + /// + /// 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 . + /// + object AddTimeout (TimeSpan time, Func callback); + + /// Removes a previously scheduled timeout + /// The token parameter is the value returned by . + /// + /// + /// if the timeout is successfully removed; otherwise, + /// + /// . + /// This method also returns + /// + /// if the timeout is not found. + 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; + +/// +/// Manages timers and idles +/// +public interface ITimedEvents +{ + /// + /// 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. + /// + /// + void AddIdle (Func idleHandler); + + /// + /// Runs all idle hooks + /// + void LockAndRunIdles (); + + /// + /// Runs all timeouts that are due + /// + void LockAndRunTimers (); + + /// + /// Called from to check if there are any outstanding timers or idle + /// handlers. + /// + /// + /// Returns the number of milliseconds remaining in the current timer (if any). Will be -1 if + /// there are no active timers. + /// + /// if there is a timer or idle handler active. + bool CheckTimersAndIdleHandlers (out int waitTimeout); + + /// Adds a timeout to the application. + /// + /// 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 . + /// + object AddTimeout (TimeSpan time, Func callback); + + /// Removes a previously scheduled timeout + /// The token parameter is the value returned by AddTimeout. + /// + /// Returns + /// + /// if the timeout is successfully removed; otherwise, + /// + /// . + /// This method also returns + /// + /// if the timeout is not found. + /// + bool RemoveTimeout (object token); + + /// + /// Returns all currently registered idles. May not include + /// actively executing idles. + /// + ReadOnlyCollection> IdleHandlers { get;} + + /// + /// Returns the next planned execution time (key - UTC ticks) + /// for each timeout that is not actively executing. + /// + SortedList Timeouts { get; } + + + /// Removes an idle handler added with from processing. + /// + /// + /// if the idle handler is successfully removed; otherwise, + /// + /// . + /// This method also returns + /// + /// if the idle handler is not found. + bool RemoveIdle (Func fnTrue); + + /// + /// Invoked when a new timeout is added. To be used in the case when + /// is . + /// + event EventHandler? 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 { /// Must report whether there are any events pending, or even block waiting for events. - /// true, if there were pending events, false otherwise. + /// , if there were pending events, otherwise. bool EventsPending (); /// The iteration function. @@ -39,13 +39,10 @@ internal interface IMainLoopDriver /// public class MainLoop : IDisposable { - internal List> _idleHandlers = new (); - internal SortedList _timeouts = new (); - - /// The idle handlers and lock that must be held while manipulating them - private readonly object _idleHandlersLock = new (); - - private readonly object _timeoutsLockToken = new (); + /// + /// Gets the class responsible for handling idles and timeouts + /// + public ITimedEvents TimedEvents { get; } = new TimedEvents(); /// Creates a new MainLoop. /// Use to release resources. @@ -59,17 +56,6 @@ internal MainLoop (IMainLoopDriver driver) driver.Setup (this); } - /// Gets a copy of the list of all idle handlers. - internal ReadOnlyCollection> IdleHandlers - { - get - { - lock (_idleHandlersLock) - { - return new List> (_idleHandlers).AsReadOnly (); - } - } - } /// The current in use. /// The main loop driver. @@ -78,11 +64,6 @@ internal ReadOnlyCollection> IdleHandlers /// Used for unit tests. internal bool Running { get; set; } - /// - /// Gets the list of all timeouts sorted by the 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. - /// - internal SortedList Timeouts => _timeouts; /// public void Dispose () @@ -99,13 +80,13 @@ public void Dispose () /// once per iteration of the main loop after other events have been handled. /// /// - /// Remove an idle handler by calling with the token this method returns. + /// Remove an idle handler by calling with the token this method returns. /// /// If the returns it will be removed and not called /// subsequently. /// /// - /// Token that can be used to remove the idle handler with . + /// Token that can be used to remove the idle handler with . // 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 AddIdle (Func idleHandler) { - lock (_idleHandlersLock) - { - _idleHandlers.Add (idleHandler); - } + TimedEvents.AddIdle (idleHandler); MainLoopDriver?.Wakeup (); return idleHandler; } - /// Adds a timeout to the . - /// - /// 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 . - /// - internal object AddTimeout (TimeSpan time, Func callback) - { - ArgumentNullException.ThrowIfNull (callback); - - var timeout = new Timeout { Span = time, Callback = callback }; - AddTimeout (time, timeout); - - return timeout; - } - - /// - /// Called from to check if there are any outstanding timers or idle - /// handlers. - /// - /// - /// Returns the number of milliseconds remaining in the current timer (if any). Will be -1 if - /// there are no active timers. - /// - /// if there is a timer or idle handler active. - 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; - } - } /// Determines whether there are pending events to be processed. /// @@ -191,50 +109,6 @@ internal bool CheckTimersAndIdleHandlers (out int waitTimeout) /// internal bool EventsPending () { return MainLoopDriver!.EventsPending (); } - /// Removes an idle handler added with from processing. - /// A token returned by - /// Returns - /// true - /// if the idle handler is successfully removed; otherwise, - /// false - /// . - /// This method also returns - /// false - /// if the idle handler is not found. - internal bool RemoveIdle (Func token) - { - lock (_idleHandlersLock) - { - return _idleHandlers.Remove (token); - } - } - - /// Removes a previously scheduled timeout - /// The token parameter is the value returned by AddTimeout. - /// Returns - /// true - /// if the timeout is successfully removed; otherwise, - /// false - /// . - /// This method also returns - /// false - /// 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; - } /// Runs the . Used only for unit tests. internal void Run () @@ -260,29 +134,13 @@ internal void Run () /// 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 (); } - /// - /// Invoked when a new timeout is added. To be used in the case when - /// is . - /// - internal event EventHandler? TimeoutAdded; /// Wakes up the that might be waiting on input. 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)); - } - } - /// - /// Finds the closest number to that is not present in - /// (incrementally). - /// - /// - /// - 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> iterate; - - lock (_idleHandlersLock) - { - iterate = _idleHandlers; - _idleHandlers = new List> (); - } - - foreach (Func idle in iterate) - { - if (idle ()) - { - lock (_idleHandlersLock) - { - _idleHandlers.Add (idle); - } - } - } - } - - private void RunTimers () - { - long now = DateTime.UtcNow.Ticks; - SortedList 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 (); - } - - 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; + +/// +/// Handles timeouts and idles +/// +public class TimedEvents : ITimedEvents +{ + internal List> _idleHandlers = new (); + internal SortedList _timeouts = new (); + + /// The idle handlers and lock that must be held while manipulating them + private readonly object _idleHandlersLock = new (); + + private readonly object _timeoutsLockToken = new (); + + + /// Gets a copy of the list of all idle handlers. + public ReadOnlyCollection> IdleHandlers + { + get + { + lock (_idleHandlersLock) + { + return new List> (_idleHandlers).AsReadOnly (); + } + } + } + + /// + /// Gets the list of all timeouts sorted by the 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. + /// + public SortedList Timeouts => _timeouts; + + /// + public void AddIdle (Func idleHandler) + { + lock (_idleHandlersLock) + { + _idleHandlers.Add (idleHandler); + } + } + + /// + public event EventHandler? 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)); + } + } + + /// + /// Finds the closest number to that is not present in + /// (incrementally). + /// + /// + /// + 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 [] iterate; + lock (_idleHandlersLock) + { + iterate = _idleHandlers.ToArray (); + _idleHandlers = new List> (); + } + + foreach (Func idle in iterate) + { + if (idle ()) + { + lock (_idleHandlersLock) + { + _idleHandlers.Add (idle); + } + } + } + } + + /// + public void LockAndRunTimers () + { + lock (_timeoutsLockToken) + { + if (_timeouts.Count > 0) + { + RunTimers (); + } + } + + } + + /// + public void LockAndRunIdles () + { + bool runIdle; + + lock (_idleHandlersLock) + { + runIdle = _idleHandlers.Count > 0; + } + + if (runIdle) + { + RunIdle (); + } + } + private void RunTimers () + { + long now = DateTime.UtcNow.Ticks; + SortedList 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 (); + } + + 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); + } + } + } + } + + /// + public bool RemoveIdle (Func token) + { + lock (_idleHandlersLock) + { + return _idleHandlers.Remove (token); + } + } + + /// Removes a previously scheduled timeout + /// The token parameter is the value returned by AddTimeout. + /// Returns + /// + /// if the timeout is successfully removed; otherwise, + /// + /// . + /// This method also returns + /// + /// 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; + } + + + /// Adds a timeout to the . + /// + /// 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 . + /// + public object AddTimeout (TimeSpan time, Func callback) + { + ArgumentNullException.ThrowIfNull (callback); + + var timeout = new Timeout { Span = time, Callback = callback }; + AddTimeout (time, timeout); + + return timeout; + } + + /// + 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; -/// for timeout events (e.g. ) -internal class TimeoutEventArgs : EventArgs +/// for timeout events (e.g. ) +public class TimeoutEventArgs : EventArgs { /// Creates a new instance of the class. /// diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiKeyboardParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiKeyboardParser.cs new file mode 100644 index 0000000000..e3de89d62f --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiKeyboardParser.cs @@ -0,0 +1,168 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// +/// Parses ansi escape sequence strings that describe keyboard activity e.g. cursor keys +/// into . +/// +public class AnsiKeyboardParser +{ + /* + *F1 \u001bOP + F2 \u001bOQ + F3 \u001bOR + F4 \u001bOS + F5 (sometimes) \u001bOt + Left Arrow \u001bOD + Right Arrow \u001bOC + Up Arrow \u001bOA + Down Arrow \u001bOB + */ + private readonly Regex _ss3Pattern = new (@"^\u001bO([PQRStDCAB])$"); + + /* + * F1 - F12 + */ + private readonly Regex _functionKey = new (@"^\u001b\[(\d+)~$"); + + // Regex patterns for ANSI arrow keys (Up, Down, Left, Right) + private readonly Regex _arrowKeyPattern = new (@"^\u001b\[(1;(\d+))?([A-D])$", RegexOptions.Compiled); + + /// + /// Parses an ANSI escape sequence into a keyboard event. Returns null if input + /// is not a recognized keyboard event or its syntax is not understood. + /// + /// + /// + public Key? ProcessKeyboardInput (string input) + { + return MapAsSs3Key(input) ?? + MapAsFunctionKey (input) ?? + MapAsArrowKey (input); + } + + private Key? MapAsSs3Key (string input) + { + // Match arrow key events + Match match = _ss3Pattern.Match (input); + + if (match.Success) + { + char finalLetter = match.Groups [1].Value.Single(); + + return finalLetter 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 + }; + } + + return null; + } + + private Key? MapAsArrowKey (string input) + { + // Match arrow key events + Match match = _arrowKeyPattern.Match (input); + + if (match.Success) + { + // Group 2 captures the modifier number, if present + string modifierGroup = match.Groups [2].Value; + char direction = match.Groups [3].Value [0]; + + Key? key = direction switch + { + 'A' => Key.CursorUp, + 'B' => Key.CursorDown, + 'C' => Key.CursorRight, + 'D' => Key.CursorLeft, + _ => null + }; + + if (key is null) + { + return null; + } + + // Examples: + // without modifiers: + // \u001b\[B + // with modifiers: + // \u001b\[1; 2B + + if (!string.IsNullOrWhiteSpace (modifierGroup) && int.TryParse (modifierGroup, out var 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 + }; + } + + return key; + } + + // It's an unrecognized keyboard event + return null; + + } + + private Key? MapAsFunctionKey (string input) + { + // Match arrow key events + Match match = _functionKey.Match (input); + + if (match.Success) + { + string functionDigit = match.Groups [1].Value; + + int digit = int.Parse (functionDigit); + + return digit switch + { + 24 => Key.F12, + 23 => Key.F11, + 21 => Key.F10, + 20 => Key.F9, + 19 => Key.F8, + 18 => Key.F7, + 17 => Key.F6, + 15 => Key.F5, + 14 => Key.F4, + 13 => Key.F3, + 12 => Key.F2, + 11 => Key.F1, + _ => null, + }; + } + + return null; + } + + /// + /// Returns if the given escape code + /// is a keyboard escape code (e.g. cursor key) + /// + /// escape code + /// + public bool IsKeyboard (string cur) { + return _ss3Pattern.IsMatch(cur) || _functionKey.IsMatch (cur) || _arrowKeyPattern.IsMatch (cur); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs new file mode 100644 index 0000000000..ee3d3a3624 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiMouseParser.cs @@ -0,0 +1,263 @@ +#nullable enable +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// +/// Parses mouse ansi escape sequences into +/// including support for pressed, released and mouse wheel. +/// +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); + + /// + /// Returns true if it is a mouse event + /// + /// + /// + 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'); + } + + /// + /// 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. + /// + /// + /// + 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 (); + + return new () + { + Position = new (x, y), + Flags = GetFlags (buttonCode, terminator) + }; + } + + // 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? now = n /// /// Sends the immediately or queues it if there is already - /// an outstanding request for the given . + /// an outstanding request for the given . /// /// /// if request was sent immediately. if it was queued. @@ -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..b3c0f97f11 100644 --- a/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs +++ b/Terminal.Gui/ConsoleDrivers/AnsiResponseParser/AnsiResponseParser.cs @@ -1,13 +1,39 @@ #nullable enable +using Microsoft.Extensions.Logging; + namespace Terminal.Gui; internal abstract class AnsiResponseParserBase : IAnsiResponseParser { + private readonly AnsiMouseParser _mouseParser = new (); + private readonly AnsiKeyboardParser _keyboardParser = new (); protected object _lockExpectedResponses = new (); protected object _lockState = new (); + /// + /// Event raised when mouse events are detected - requires setting to true + /// + public event EventHandler? Mouse; + + /// + /// Event raised when keyboard event is detected (e.g. cursors) - requires setting + /// + public event Action? Keyboard; + + /// + /// True to explicitly handle mouse escape sequences by passing them to event. + /// Defaults to + /// + public bool HandleMouse { get; set; } = false; + + /// + /// True to explicitly handle keyboard escape sequences (such as cursor keys) by passing them to + /// event + /// + public bool HandleKeyboard { get; set; } = false; + /// /// Responses we are expecting to come in. /// @@ -58,7 +84,6 @@ protected set 'l', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' ]); - protected AnsiResponseParserBase (IHeld heldContent) { _heldContent = heldContent; } protected void ResetState () @@ -118,7 +143,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,16 +154,18 @@ 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 (currentChar == '[' || currentChar == 'O') { - // Detected '[', transition to InResponse state + //We need O for SS3 mode F1-F4 e.g. "OP" => F1 + + // Detected '[' or 'O', transition to InResponse state State = AnsiResponseParserState.InResponse; _heldContent.AddToHeld (currentObj); // Hold the '[' } @@ -185,6 +212,26 @@ protected bool ShouldReleaseHeldContent () { string cur = _heldContent.HeldToString (); + if (HandleMouse && IsMouse (cur)) + { + RaiseMouseEvent (cur); + ResetState (); + + Logging.Logger.LogTrace ($"AnsiResponseParser handled as mouse '{cur}'"); + + return false; + } + + if (HandleKeyboard && IsKeyboard (cur)) + { + RaiseKeyboardEvent (cur); + ResetState (); + + Logging.Logger.LogTrace ($"AnsiResponseParser handled as keyboard '{cur}'"); + + return false; + } + lock (_lockExpectedResponses) { // Look for an expected response for what is accumulated so far (since Esc) @@ -232,6 +279,8 @@ protected bool ShouldReleaseHeldContent () { _heldContent.ClearHeld (); + Logging.Logger.LogTrace ($"AnsiResponseParser bespoke processed '{cur}'"); + // Do not send back to input stream return false; } @@ -244,6 +293,34 @@ 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); } + + private void RaiseKeyboardEvent (string cur) + { + Key? k = _keyboardParser.ProcessKeyboardInput (cur); + + if (k is null) + { + Logging.Logger.LogError ($"Failed to determine a Key for given Keyboard escape sequence '{cur}'"); + } + else + { + Keyboard?.Invoke (this, k); + } + } + + private bool IsKeyboard (string cur) { return _keyboardParser.IsKeyboard (cur); } + /// /// /// When overriden in a derived class, indicates whether the unexpected response @@ -265,6 +342,8 @@ private bool MatchResponse (string cur, List collection if (matchingResponse?.Response != null) { + Logging.Logger.LogTrace ($"AnsiResponseParser processed '{cur}'"); + if (invokeCallback) { matchingResponse.Response.Invoke (_heldContent); @@ -339,8 +418,10 @@ public void StopExpecting (string terminator, bool persistent) } } -internal class AnsiResponseParser () : AnsiResponseParserBase (new GenericHeld ()) +internal class AnsiResponseParser : AnsiResponseParserBase { + public AnsiResponseParser () : base (new GenericHeld ()) { } + /// public Func>, bool> UnexpectedResponseHandler { get; set; } = _ => false; @@ -351,12 +432,20 @@ public IEnumerable> ProcessInput (params Tuple [] input) ProcessInputBase ( i => input [i].Item1, i => input [i], - c => output.Add ((Tuple)c), + c => AppendOutput (output, c), input.Length); return output; } + private void AppendOutput (List> output, object c) + { + Tuple tuple = (Tuple)c; + + Logging.Logger.LogTrace ($"AnsiResponseParser releasing '{tuple.Item1}'"); + output.Add (tuple); + } + public Tuple [] Release () { // Lock in case Release is called from different Thread from parse @@ -421,12 +510,18 @@ 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.Logger.LogTrace ($"AnsiResponseParser releasing '{c}'"); + output.Append (c); + } + public string Release () { lock (_lockState) 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 /// /// 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). /// - ExpectingBracket, + ExpectingEscapeSequence, /// /// Parser has encountered Esc[ and considers that it is in the process 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; /// /// 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.). /// public interface IAnsiResponseParser { 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; -/// Base class for Terminal.Gui ConsoleDriver implementations. +/// Base class for Terminal.Gui IConsoleDriver implementations. /// /// There are currently four implementations: - (for Unix and Mac) - /// - that uses the .NET Console API - @@ -558,19 +558,19 @@ public void Refresh () #region Color Handling - /// Gets whether the supports TrueColor output. + /// Gets whether the supports TrueColor output. 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 /// - /// Gets or sets whether the should use 16 colors instead of the default TrueColors. + /// Gets or sets whether the should use 16 colors instead of the default TrueColors. /// See to change this setting via . /// /// /// - /// Will be forced to if is - /// , indicating that the cannot support TrueColor. + /// Will be forced to if is + /// , indicating that the cannot support TrueColor. /// /// 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; -/// Implements a mock ConsoleDriver for unit testing +/// Implements a mock IConsoleDriver for unit testing 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 /// The rectangle describing the of region. Region? Clip { get; set; } + /// /// Gets the column last set by . and are used by /// and to determine where to add content. @@ -33,8 +34,7 @@ public interface IConsoleDriver // BUGBUG: This should not be publicly settable. /// - /// Gets or sets the contents of the application output. The driver outputs this buffer to the terminal when - /// is called. + /// Gets or sets the contents of the application output. The driver outputs this buffer to the terminal. /// The format of the array is rows, columns. The first index is the row, the second index is the column. /// Cell [,]? Contents { get; set; } @@ -93,17 +93,6 @@ public interface IConsoleDriver /// bool IsRuneSupported (Rune rune); - // BUGBUG: This is not referenced. Can it be removed? - /// Tests whether the specified coordinate are valid for drawing. - /// The column. - /// The row. - /// - /// if the coordinate is outside the screen bounds or outside of - /// . - /// otherwise. - /// - bool IsValidLocation (int col, int row); - /// Tests whether the specified coordinate are valid for drawing the specified Rune. /// Used to determine if one or two columns are required. /// The column. @@ -173,9 +162,15 @@ public interface IConsoleDriver /// String. void AddStr (string str); + /// Clears the of the driver. + void ClearContents (); + /// /// Fills the specified rectangle with the specified rune, using /// + event EventHandler ClearedContents; + + /// Fills the specified rectangle with the specified rune, using /// /// The value of is honored. Any parts of the rectangle not in the clip will not be /// drawn. @@ -192,31 +187,15 @@ public interface IConsoleDriver /// void FillRect (Rectangle rect, char c); - /// Clears the of the driver. - void ClearContents (); - - /// - /// Raised each time is called. For benchmarking. - /// - event EventHandler? ClearedContents; /// Gets the terminal cursor visibility. /// The current /// upon success bool GetCursorVisibility (out CursorVisibility visibility); - /// Called when the terminal size changes. Fires the event. - /// - void OnSizeChanged (SizeChangedEventArgs args); - /// Updates the screen to reflect all the changes that have been done to the display buffer void Refresh (); - /// - /// Raised each time is called. For benchmarking. - /// - event EventHandler>? Refreshed; - /// Sets the terminal cursor visibility. /// The wished /// upon success @@ -235,10 +214,6 @@ public interface IConsoleDriver /// void UpdateCursor (); - /// Redraws the physical screen with the contents that have been queued up via any of the printing commands. - /// if any updates to the screen were made. - bool UpdateScreen (); - /// Initializes the driver /// Returns an instance of using the for the driver. MainLoop Init (); @@ -264,21 +239,9 @@ public interface IConsoleDriver /// Event fired when a mouse event occurs. event EventHandler? MouseEvent; - /// Called when a mouse event occurs. Fires the event. - /// - void OnMouseEvent (MouseEventArgs a); - /// Event fired when a key is pressed down. This is a precursor to . event EventHandler? KeyDown; - // BUGBUG: This is not referenced. Can it be removed? - /// - /// Called when a key is pressed down. Fires the event. This is a precursor to - /// . - /// - /// - void OnKeyDown (Key a); - /// Event fired when a key is released. /// /// Drivers that do not support key release events will fire this event after @@ -287,16 +250,6 @@ public interface IConsoleDriver /// event EventHandler? KeyUp; - // BUGBUG: This is not referenced. Can it be removed? - /// Called when a key is released. Fires the event. - /// - /// Drivers that do not support key release events will call this method after - /// processing - /// is complete. - /// - /// - void OnKeyUp (Key a); - /// Simulates a key press. /// The key character. /// The key. @@ -305,11 +258,6 @@ public interface IConsoleDriver /// If simulates the Ctrl key being pressed. void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl); - /// - /// How long after Esc has been pressed before we give up on getting an Ansi escape sequence - /// - public TimeSpan EscTimeout { get; } - /// /// Queues the given for execution /// 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 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..73f9087f39 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ApplicationV2.cs @@ -0,0 +1,239 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// Implementation of that boots the new 'v2' +/// main loop architecture. +/// +public class ApplicationV2 : ApplicationImpl +{ + private readonly Func _netInputFactory; + private readonly Func _netOutputFactory; + private readonly Func _winInputFactory; + private readonly Func _winOutputFactory; + private IMainLoopCoordinator? _coordinator; + private string? _driverName; + + private readonly ITimedEvents _timedEvents = new TimedEvents (); + + /// + /// Creates anew instance of the Application backend. The provided + /// factory methods will be used on Init calls to get things booted. + /// + public ApplicationV2 () : this ( + () => new NetInput (), + () => new NetOutput (), + () => new WindowsInput (), + () => new WindowsOutput () + ) + { } + + internal ApplicationV2 ( + Func netInputFactory, + Func netOutputFactory, + Func winInputFactory, + Func winOutputFactory + ) + { + _netInputFactory = netInputFactory; + _netOutputFactory = netOutputFactory; + _winInputFactory = winInputFactory; + _winOutputFactory = winOutputFactory; + IsLegacy = false; + } + + /// + [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 inputBuffer = new ConcurrentQueue (); + MainLoop loop = new MainLoop (); + + return new MainLoopCoordinator ( + _timedEvents, + _winInputFactory, + inputBuffer, + new WindowsInputProcessor (inputBuffer), + _winOutputFactory, + loop); + } + + private IMainLoopCoordinator CreateNetSubcomponents () + { + ConcurrentQueue inputBuffer = new ConcurrentQueue (); + MainLoop loop = new MainLoop (); + + return new MainLoopCoordinator ( + _timedEvents, + _netInputFactory, + inputBuffer, + new NetInputProcessor (inputBuffer), + _netOutputFactory, + loop); + } + + /// + [RequiresUnreferencedCode ("AOT")] + [RequiresDynamicCode ("AOT")] + public override T Run (Func? errorHandler = null, IConsoleDriver? driver = null) + { + var top = new T (); + + Run (top, errorHandler); + + return top; + } + + /// + public override void Run (Toplevel view, Func? 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 Exception ($"{nameof (IMainLoopCoordinator)}inexplicably became null during Run"); + } + _coordinator.RunIteration (); + } + } + + /// + public override void Shutdown () + { + _coordinator?.Stop (); + base.Shutdown (); + Application.Driver = null; + } + + /// + 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; + } + } + + /// + public override void Invoke (Action action) + { + _timedEvents.AddIdle ( + () => + { + action (); + + return false; + } + ); + } + + /// + public override void AddIdle (Func func) { _timedEvents.AddIdle (func); } + + /// + /// Removes an idle function added by + /// + /// Function to remove + /// True if it was found and removed + public bool RemoveIdle (Func fnTrue) + { + return _timedEvents.RemoveIdle (fnTrue); + } + + /// + public override object AddTimeout (TimeSpan time, Func callback) { return _timedEvents.AddTimeout (time, callback); } + + /// + 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..25ee57552d --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ConsoleDriverFacade.cs @@ -0,0 +1,387 @@ +using System.Runtime.InteropServices; + +namespace Terminal.Gui; + +internal class ConsoleDriverFacade : IConsoleDriver +{ + private readonly IInputProcessor _inputProcessor; + private readonly IConsoleOutput _output; + private readonly IOutputBuffer _outputBuffer; + private readonly AnsiRequestScheduler _ansiRequestScheduler; + private CursorVisibility _lastCursor = CursorVisibility.Default; + + /// The event fired when the terminal is resized. + public event EventHandler SizeChanged; + + 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 (); + } + } + + /// Gets the location and size of the terminal screen. + public Rectangle Screen => new (new (0, 0), _output.GetWindowSize ()); + + /// + /// Gets or sets the clip rectangle that and are subject + /// to. + /// + /// The rectangle describing the of region. + public Region Clip + { + get => _outputBuffer.Clip; + set => _outputBuffer.Clip = value; + } + + /// Get the operating system clipboard. + public IClipboard Clipboard { get; private set; } = new FakeDriver.FakeClipboard (); + + /// + /// Gets the column last set by . and are used by + /// and to determine where to add content. + /// + public int Col => _outputBuffer.Col; + + /// The number of columns visible in the terminal. + public int Cols + { + get => _outputBuffer.Cols; + set => _outputBuffer.Cols = value; + } + + /// + /// The contents of the application output. The driver outputs this buffer to the terminal. + /// The format of the array is rows, columns. The first index is the row, the second index is the column. + /// + public Cell [,] Contents + { + get => _outputBuffer.Contents; + set => _outputBuffer.Contents = value; + } + + /// The leftmost column in the terminal. + public int Left + { + get => _outputBuffer.Left; + set => _outputBuffer.Left = value; + } + + /// + /// Gets the row last set by . and are used by + /// and to determine where to add content. + /// + public int Row => _outputBuffer.Row; + + /// The number of rows visible in the terminal. + public int Rows + { + get => _outputBuffer.Rows; + set => _outputBuffer.Rows = value; + } + + /// The topmost row in the terminal. + public int Top + { + get => _outputBuffer.Top; + set => _outputBuffer.Top = value; + } + + // TODO: Probably not everyone right? + + /// Gets whether the supports TrueColor output. + public bool SupportsTrueColor => true; + + // TODO: Currently ignored + /// + /// Gets or sets whether the should use 16 colors instead of the default TrueColors. + /// See to change this setting via . + /// + /// + /// + /// Will be forced to if is + /// , indicating that the cannot support TrueColor. + /// + /// + public bool Force16Colors { get; set; } + + /// + /// The that will be used for the next or + /// call. + /// + public Attribute CurrentAttribute + { + get => _outputBuffer.CurrentAttribute; + set => _outputBuffer.CurrentAttribute = value; + } + + /// Adds the specified rune to the display at the current cursor position. + /// + /// + /// When the method returns, will be incremented by the number of columns + /// required, even if the new column value is outside of the + /// or screen + /// dimensions defined by . + /// + /// + /// If requires more than one column, and plus the number + /// of columns + /// needed exceeds the or screen dimensions, the default Unicode replacement + /// character (U+FFFD) + /// will be added instead. + /// + /// + /// Rune to add. + public void AddRune (Rune rune) { _outputBuffer.AddRune (rune); } + + /// + /// Adds the specified to the display at the current cursor position. This method is a + /// convenience method that calls with the + /// constructor. + /// + /// Character to add. + public void AddRune (char c) { _outputBuffer.AddRune (c); } + + /// Adds the to the display at the cursor position. + /// + /// + /// When the method returns, will be incremented by the number of columns + /// required, unless the new column value is outside of the + /// or screen + /// dimensions defined by . + /// + /// If requires more columns than are available, the output will be clipped. + /// + /// String. + public void AddStr (string str) { _outputBuffer.AddStr (str); } + + /// Clears the of the driver. + public void ClearContents () + { + _outputBuffer.ClearContents (); + ClearedContents?.Invoke (this, new MouseEventArgs ()); + } + + /// + /// Raised each time is called. For benchmarking. + /// + public event EventHandler ClearedContents; + + /// + /// Fills the specified rectangle with the specified rune, using + /// + /// + /// The value of is honored. Any parts of the rectangle not in the clip will not be + /// drawn. + /// + /// The Screen-relative rectangle. + /// The Rune used to fill the rectangle + public void FillRect (Rectangle rect, Rune rune = default) { _outputBuffer.FillRect (rect, rune); } + + /// + /// Fills the specified rectangle with the specified . This method is a convenience method + /// that calls . + /// + /// + /// + public void FillRect (Rectangle rect, char c) { _outputBuffer.FillRect (rect, c); } + + /// + public virtual string GetVersionInfo () + { + string type = ""; + + if (_inputProcessor is WindowsInputProcessor) + { + type = "(win)"; + } + else if (_inputProcessor is NetInputProcessor) + { + type = "(net)"; + } + + return GetType().Name.TrimEnd('`','1') + type; + } + + /// Tests if the specified rune is supported by the driver. + /// + /// + /// if the rune can be properly presented; if the driver does not + /// support displaying this rune. + /// + public bool IsRuneSupported (Rune rune) { return Rune.IsValid (rune.Value); } + + /// Tests whether the specified coordinate are valid for drawing the specified Rune. + /// Used to determine if one or two columns are required. + /// The column. + /// The row. + /// + /// if the coordinate is outside the screen bounds or outside of + /// . + /// otherwise. + /// + public bool IsValidLocation (Rune rune, int col, int row) { return _outputBuffer.IsValidLocation (rune, col, row); } + + /// + /// Updates and to the specified column and row in + /// . + /// Used by and to determine + /// where to add content. + /// + /// + /// This does not move the cursor on the screen, it only updates the internal state of the driver. + /// + /// If or are negative or beyond + /// and + /// , the method still sets those properties. + /// + /// + /// Column to move to. + /// Row to move to. + public void Move (int col, int row) { _outputBuffer.Move (col, row); } + + // TODO: Probably part of output + + /// Sets the terminal cursor visibility. + /// The wished + /// upon success + public bool SetCursorVisibility (CursorVisibility visibility) + { + _lastCursor = visibility; + _output.SetCursorVisibility (visibility); + + return true; + } + + /// + public bool GetCursorVisibility (out CursorVisibility current) + { + current = _lastCursor; + + return true; + } + + /// + public void Suspend () { } + + /// + /// Sets the position of the terminal cursor to and + /// . + /// + public void UpdateCursor () { _output.SetCursorPosition (Col, Row); } + + /// Initializes the driver + /// Returns an instance of using the for the driver. + public MainLoop Init () { throw new NotSupportedException (); } + + /// Ends the execution of the console driver. + public void End () + { + // TODO: Nope + } + + /// Selects the specified attribute as the attribute to use for future calls to AddRune and AddString. + /// Implementations should call base.SetAttribute(c). + /// C. + public Attribute SetAttribute (Attribute c) { return _outputBuffer.CurrentAttribute = c; } + + /// Gets the current . + /// The current attribute. + public Attribute GetAttribute () { return _outputBuffer.CurrentAttribute; } + + /// Makes an . + /// The foreground color. + /// The background color. + /// The attribute for the foreground and background colors. + 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 + ); + } + + /// Event fired when a key is pressed down. This is a precursor to . + public event EventHandler KeyDown; + + /// Event fired when a key is released. + /// + /// Drivers that do not support key release events will fire this event after + /// processing is + /// complete. + /// + public event EventHandler KeyUp; + + /// Event fired when a mouse event occurs. + public event EventHandler MouseEvent; + + /// Simulates a key press. + /// The key character. + /// The key. + /// If simulates the Shift key being pressed. + /// If simulates the Alt key being pressed. + /// If simulates the Ctrl key being pressed. + public void SendKeys (char keyChar, ConsoleKey key, bool shift, bool alt, bool ctrl) + { + // TODO: implement + } + + /// + /// Provide proper writing to send escape sequence recognized by the . + /// + /// + public void WriteRaw (string ansi) { _output.Write (ansi); } + + /// + /// Queues the given for execution + /// + /// + public void QueueAnsiRequest (AnsiEscapeSequenceRequest request) { _ansiRequestScheduler.SendOrSchedule (request); } + + public AnsiRequestScheduler GetRequestScheduler () { return _ansiRequestScheduler; } + + /// + 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..3c93608fc4 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/ConsoleInput.cs @@ -0,0 +1,81 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace Terminal.Gui; + +/// +/// Base class for reading console input in perpetual loop +/// +/// +public abstract class ConsoleInput : IConsoleInput +{ + private ConcurrentQueue? _inputBuffer; + + /// + /// Determines how to get the current system type, adjust + /// in unit tests to simulate specific timings. + /// + public Func Now { get; set; } = () => DateTime.Now; + + + /// + public virtual void Dispose () { } + + /// + public void Initialize (ConcurrentQueue inputBuffer) { _inputBuffer = inputBuffer; } + + /// + 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) + { } + } + + /// + /// When implemented in a derived class, returns true if there is data available + /// to read from console. + /// + /// + protected abstract bool Peek (); + + /// + /// Returns the available data without blocking, called when + /// returns . + /// + /// + protected abstract IEnumerable Read (); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs b/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs new file mode 100644 index 0000000000..ee80a8081f --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IConsoleInput.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace Terminal.Gui; + +/// +/// 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. +/// +/// +public interface IConsoleInput : IDisposable +{ + /// + /// Initializes the input with a buffer into which to put data read + /// + /// + void Initialize (ConcurrentQueue inputBuffer); + + /// + /// Runs in an infinite input loop. + /// + /// + /// + /// Raised when token is + /// cancelled. This is the only means of exiting the input. + /// + 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..2b16dd8b8c --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IConsoleOutput.cs @@ -0,0 +1,42 @@ +namespace Terminal.Gui; + +/// +/// Interface for writing console output +/// +public interface IConsoleOutput : IDisposable +{ + /// + /// Writes the given text directly to the console. Use to send + /// ansi escape codes etc. Regular screen output should use the + /// overload. + /// + /// + void Write (string text); + + /// + /// Write the contents of the to the console + /// + /// + void Write (IOutputBuffer buffer); + + /// + /// Returns the current size of the console window in rows/columns (i.e. + /// of characters not pixels). + /// + /// + public Size GetWindowSize (); + + /// + /// Updates the console cursor (the blinking underscore) to be hidden, + /// visible etc. + /// + /// + void SetCursorVisibility (CursorVisibility visibility); + + /// + /// Moves the console cursor to the given location. + /// + /// + /// + 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..64fdb0f396 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IInputProcessor.cs @@ -0,0 +1,57 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Interface for main loop class that will process the queued input buffer contents. +/// Is responsible for and translating into common Terminal.Gui +/// events and data models. +/// +public interface IInputProcessor +{ + /// Event fired when a key is pressed down. This is a precursor to . + event EventHandler? KeyDown; + + /// Event fired when a key is released. + /// + /// Drivers that do not support key release events will fire this event after processing is + /// complete. + /// + event EventHandler? KeyUp; + + /// Event fired when a mouse event occurs. + event EventHandler? MouseEvent; + + /// + /// Called when a key is pressed down. Fires the event. This is a precursor to + /// . + /// + /// The key event data. + void OnKeyDown (Key key); + + /// + /// Called when a key is released. Fires the event. + /// + /// + /// Drivers that do not support key release events will call this method after processing + /// is complete. + /// + /// The key event data. + void OnKeyUp (Key key); + + /// + /// Called when a mouse event occurs. Fires the event. + /// + /// The mouse event data. + void OnMouseEvent (MouseEventArgs mouseEventArgs); + + /// + /// Drains the input buffer, processing all available keystrokes + /// + void ProcessQueue (); + + /// + /// Gets the response parser currently configured on this input processor. + /// + /// + 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..f53695a397 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IKeyConverter.cs @@ -0,0 +1,18 @@ +namespace Terminal.Gui; + +/// +/// Interface for subcomponent of a which +/// can translate the raw console input type T (which typically varies by +/// driver) to the shared Terminal.Gui class. +/// +/// +public interface IKeyConverter +{ + /// + /// Converts the native keyboard class read from console into + /// the shared class used by Terminal.Gui views. + /// + /// + /// + Key ToKey(T value); +} \ No newline at end of file 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; + +/// +/// Interface for main loop that runs the core Terminal.Gui UI loop. +/// +/// +public interface IMainLoop : IDisposable +{ + /// + /// Gets the class responsible for servicing user timeouts and idles + /// + public ITimedEvents TimedEvents { get; } + + /// + /// Gets the class responsible for writing final rendered output to the console + /// + public IOutputBuffer OutputBuffer { get; } + + /// + /// Class for writing output to the console. + /// + public IConsoleOutput Out { get; } + + /// + /// Gets the class responsible for processing buffered console input and translating + /// it into events on the UI thread. + /// + public IInputProcessor InputProcessor { get; } + + /// + /// Gets the class responsible for sending ANSI escape requests which expect a response + /// from the remote terminal e.g. Device Attribute Request + /// + public AnsiRequestScheduler AnsiRequestScheduler { get; } + + /// + /// Gets the class responsible for determining the current console size + /// + public IWindowSizeMonitor WindowSizeMonitor { get; } + + /// + /// Initializes the loop with a buffer from which data can be read + /// + /// + /// + /// + /// + void Initialize (ITimedEvents timedEvents, ConcurrentQueue inputBuffer, IInputProcessor inputProcessor, IConsoleOutput consoleOutput); + + /// + /// 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. + /// + 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..dd3598dbcb --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IMainLoopCoordinator.cs @@ -0,0 +1,25 @@ +namespace Terminal.Gui; + +/// +/// Interface for main Terminal.Gui loop manager in v2. +/// +public interface IMainLoopCoordinator +{ + /// + /// Create all required subcomponents and boot strap. + /// + /// + public Task StartAsync (); + + + /// + /// Stops the input thread, blocking till it exits. + /// Call this method only from the main UI loop. + /// + public void Stop (); + + /// + /// Run a single iteration of the main UI loop + /// + 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 +{ } diff --git a/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs b/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs new file mode 100644 index 0000000000..01de7958c7 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IOutputBuffer.cs @@ -0,0 +1,122 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// 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. +/// +public interface IOutputBuffer +{ + /// + /// 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. + /// + public bool [] DirtyLines { get; } + + /// + /// The contents of the application output. The driver outputs this buffer to the terminal when UpdateScreen is called. + /// + Cell [,] Contents { get; set; } + + /// + /// Gets or sets the clip rectangle that and are subject + /// to. + /// + /// The rectangle describing the of region. + public Region? Clip { get; set; } + + /// + /// The that will be used for the next AddRune or AddStr call. + /// + Attribute CurrentAttribute { get; set; } + + /// The number of rows visible in the terminal. + int Rows { get; set; } + + /// The number of columns visible in the terminal. + int Cols { get; set; } + + /// + /// Gets the row last set by . and are used by + /// and to determine where to add content. + /// + public int Row { get; } + + /// + /// Gets the column last set by . and are used by + /// and to determine where to add content. + /// + public int Col { get; } + + /// + /// The first cell index on left of screen - basically always 0. + /// Changing this may have unexpected consequences. + /// + int Left { get; set; } + + /// + /// The first cell index on top of screen - basically always 0. + /// Changing this may have unexpected consequences. + /// + int Top { get; set; } + + /// + /// Updates the column and row to the specified location in the buffer. + /// + /// The column to move to. + /// The row to move to. + void Move (int col, int row); + + /// Adds the specified rune to the display at the current cursor position. + /// Rune to add. + void AddRune (Rune rune); + + /// + /// Adds the specified character to the display at the current cursor position. This is a convenience method for + /// AddRune. + /// + /// Character to add. + void AddRune (char c); + + /// Adds the string to the display at the current cursor position. + /// String to add. + void AddStr (string str); + + /// Clears the contents of the buffer. + void ClearContents (); + + /// + /// Tests whether the specified coordinate is valid for drawing the specified Rune. + /// + /// Used to determine if one or two columns are required. + /// The column. + /// The row. + /// + /// True if the coordinate is valid for the Rune; false otherwise. + /// + bool IsValidLocation (Rune rune, int col, int row); + + /// + /// Changes the size of the buffer to the given size + /// + /// + /// + void SetWindowSize (int cols, int rows); + + /// + /// Fills the given with the given + /// symbol using the currently selected attribute. + /// + /// + /// + void FillRect (Rectangle rect, Rune rune); + + /// + /// Fills the given with the given + /// symbol using the currently selected attribute. + /// + /// + /// + void FillRect (Rectangle rect, char rune); +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs b/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs new file mode 100644 index 0000000000..ba961cf444 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/IWindowSizeMonitor.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Interface for classes responsible for reporting the current +/// size of the terminal window. +/// +public interface IWindowSizeMonitor +{ + /// Invoked when the terminal's size changed. The new size of the terminal is provided. + event EventHandler? SizeChanging; + + /// + /// Examines the current size of the terminal and raises if it is different + /// from last inspection. + /// + /// + 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 +{ } diff --git a/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs b/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs new file mode 100644 index 0000000000..4ce2b40a18 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/InputProcessor.cs @@ -0,0 +1,144 @@ +#nullable enable +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// Processes the queued input buffer contents - which must be of Type . +/// Is responsible for and translating into common Terminal.Gui +/// events and data models. +/// +public abstract class InputProcessor : IInputProcessor +{ + /// + /// How long after Esc has been pressed before we give up on getting an Ansi escape sequence + /// + private readonly TimeSpan _escTimeout = TimeSpan.FromMilliseconds (50); + + internal AnsiResponseParser Parser { get; } = new (); + + public IKeyConverter KeyConverter { get; } + + /// + /// Input buffer which will be drained from by this class. + /// + public ConcurrentQueue InputBuffer { get; } + + /// + public IAnsiResponseParser GetParser () { return Parser; } + + private readonly MouseInterpreter _mouseInterpreter = new (); + + /// Event fired when a key is pressed down. This is a precursor to . + public event EventHandler? KeyDown; + + /// + /// Called when a key is pressed down. Fires the event. This is a precursor to + /// . + /// + /// + public void OnKeyDown (Key a) { KeyDown?.Invoke (this, a); } + + /// Event fired when a key is released. + /// + /// Drivers that do not support key release events will fire this event after processing is + /// complete. + /// + public event EventHandler? KeyUp; + + /// Called when a key is released. Fires the event. + /// + /// Drivers that do not support key release events will call this method after processing + /// is complete. + /// + /// + public void OnKeyUp (Key a) { KeyUp?.Invoke (this, a); } + + /// Event fired when a mouse event occurs. + public event EventHandler? MouseEvent; + + /// Called when a mouse event occurs. Fires the event. + /// + public void OnMouseEvent (MouseEventArgs a) + { + // Ensure ScreenPosition is set + a.ScreenPosition = a.Position; + + foreach (var e in _mouseInterpreter.Process (a)) + { + Logging.Logger.LogTrace ($"Mouse Interpreter raising {e.Flags}"); + // Pass on + MouseEvent?.Invoke (this, e); + } + } + + /// + /// Constructs base instance including wiring all relevant + /// parser events and setting to + /// the provided thread safe input collection. + /// + /// + protected InputProcessor (ConcurrentQueue inputBuffer, IKeyConverter 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 => + { + Logging.Logger.LogInformation ($"{nameof (InputProcessor)} ignored unrecognized response '{new string (str.Select (k => k.Item1).ToArray ())}'"); + return true; + }; + KeyConverter = keyConverter; + } + + /// + /// Drains the buffer, processing all available keystrokes + /// + public void ProcessQueue () + { + while (InputBuffer.TryDequeue (out T? input)) + { + Process (input); + } + + foreach (T input in ReleaseParserHeldKeysIfStale ()) + { + ProcessAfterParsing (input); + } + } + + private IEnumerable ReleaseParserHeldKeysIfStale () + { + if (Parser.State == AnsiResponseParserState.ExpectingEscapeSequence && DateTime.Now - Parser.StateChangedAt > _escTimeout) + { + return Parser.Release ().Select (o => o.Item2); + } + + return []; + } + + /// + /// Process the provided single input element . This method + /// is called sequentially for each value read from . + /// + /// + protected abstract void Process (T input); + + /// + /// Process the provided single input element - short-circuiting the + /// stage of the processing. + /// + /// + 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..68bada3fe3 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/Logging.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Terminal.Gui; + +/// +/// 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). +/// +/// +/// Also contains the +/// instance that should be used for internal metrics +/// (iteration timing etc). +/// +public static class Logging +{ + /// + /// Logger, defaults to NullLogger (i.e. no logging). Set this to a + /// file logger to enable logging of Terminal.Gui internals. + /// + public static ILogger Logger { get; set; } = NullLogger.Instance; + + /// + /// Metrics reporting meter for internal Terminal.Gui processes. To use + /// create your own static instrument e.g. CreateCounter, CreateHistogram etc + /// + internal static readonly Meter Meter = new ("Terminal.Gui"); + + /// + /// Metric for how long it takes each full iteration of the main loop to occur + /// + public static readonly Histogram TotalIterationMetric = Logging.Meter.CreateHistogram ("Iteration (ms)"); + + /// + /// Metric for how long it took to do the 'timeouts and invokes' section of main loop. + /// + public static readonly Histogram IterationInvokesAndTimeouts = Logging.Meter.CreateHistogram ("Invokes & Timers (ms)"); + + /// + /// Counter for when we redraw, helps detect situations e.g. where we are repainting entire UI every loop + /// + public static readonly Counter Redraws = Logging.Meter.CreateCounter ("Redraws"); + + /// + /// Metric for how long it takes to read all available input from the input stream - at which + /// point input loop will sleep. + /// + public static readonly Histogram DrainInputStream = Logging.Meter.CreateHistogram ("Drain Input (ms)"); + +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs b/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs new file mode 100644 index 0000000000..0aaeca2409 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MainLoop.cs @@ -0,0 +1,209 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +public class MainLoop : IMainLoop +{ + private ITimedEvents? _timedEvents; + private ConcurrentQueue? _inputBuffer; + private IInputProcessor? _inputProcessor; + private IConsoleOutput? _out; + private AnsiRequestScheduler? _ansiRequestScheduler; + private IWindowSizeMonitor? _windowSizeMonitor; + + /// + public ITimedEvents TimedEvents + { + get => _timedEvents ?? throw new NotInitializedException(nameof(TimedEvents)); + private set => _timedEvents = value; + } + + // TODO: follow above pattern for others too + + /// + /// The input events thread-safe collection. This is populated on separate + /// thread by a . Is drained as part of each + /// + /// + public ConcurrentQueue InputBuffer + { + get => _inputBuffer ?? throw new NotInitializedException (nameof (InputBuffer)); + private set => _inputBuffer = value; + } + + /// + public IInputProcessor InputProcessor + { + get => _inputProcessor ?? throw new NotInitializedException (nameof (InputProcessor)); + private set => _inputProcessor = value; + } + + /// + public IOutputBuffer OutputBuffer { get; } = new OutputBuffer (); + + /// + public IConsoleOutput Out + { + get => _out ?? throw new NotInitializedException (nameof (Out)); + private set => _out = value; + } + + /// + public AnsiRequestScheduler AnsiRequestScheduler + { + get => _ansiRequestScheduler ?? throw new NotInitializedException (nameof (AnsiRequestScheduler)); + private set => _ansiRequestScheduler = value; + } + + /// + public IWindowSizeMonitor WindowSizeMonitor + { + get => _windowSizeMonitor ?? throw new NotInitializedException (nameof (WindowSizeMonitor)); + private set => _windowSizeMonitor = value; + } + + /// + /// Determines how to get the current system type, adjust + /// in unit tests to simulate specific timings. + /// + public Func Now { get; set; } = () => DateTime.Now; + + /// + /// Initializes the class with the provided subcomponents + /// + /// + /// + /// + /// + public void Initialize (ITimedEvents timedEvents, ConcurrentQueue inputBuffer, IInputProcessor inputProcessor, IConsoleOutput consoleOutput) + { + InputBuffer = inputBuffer; + Out = consoleOutput; + InputProcessor = inputProcessor; + + TimedEvents = timedEvents; + AnsiRequestScheduler = new (InputProcessor.GetParser ()); + + WindowSizeMonitor = new WindowSizeMonitor (Out, OutputBuffer); + } + + /// + 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 (); + + HackLayoutDrawIfTopChanged (); + + 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); + } + + this.SetCursor (); + } + + var swCallbacks = Stopwatch.StartNew (); + + TimedEvents.LockAndRunTimers (); + + TimedEvents.LockAndRunIdles (); + + Logging.IterationInvokesAndTimeouts.Record (swCallbacks.Elapsed.Milliseconds); + } + + private View? _lastTop; + private void HackLayoutDrawIfTopChanged () + { + // TODO: This fixes closing a modal not making its host (below) refresh + // until you click. This should not be the job of the main loop! + var newTop = Application.Top; + if (_lastTop != null && _lastTop != newTop && newTop != null) + { + newTop.SetNeedsDraw(); + } + + _lastTop = Application.Top; + } + + 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.Logger.LogTrace ( $"{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; + } + + /// + 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..881a3b89b5 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MainLoopCoordinator.cs @@ -0,0 +1,185 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// +/// Handles creating the input loop thread and bootstrapping the +/// that handles layout/drawing/events etc. +/// +/// This class is designed to be managed by +/// +/// +internal class MainLoopCoordinator : IMainLoopCoordinator +{ + private readonly Func> _inputFactory; + private readonly ConcurrentQueue _inputBuffer; + private readonly IInputProcessor _inputProcessor; + private readonly IMainLoop _loop; + private readonly CancellationTokenSource _tokenSource = new (); + private readonly Func _outputFactory; + private IConsoleInput _input; + private IConsoleOutput _output; + private readonly object _oLockInitialization = new (); + private ConsoleDriverFacade _facade; + private Task _inputTask; + private readonly ITimedEvents _timedEvents; + + private readonly SemaphoreSlim _startupSemaphore = new (0, 1); + + /// + /// Creates a new coordinator + /// + /// + /// + /// Function to create a new input. This must call + /// explicitly and cannot return an existing instance. This requirement arises because Windows + /// console screen buffer APIs are thread-specific for certain operations. + /// + /// + /// + /// + /// Function to create a new output. This must call + /// explicitly and cannot return an existing instance. This requirement arises because Windows + /// console screen buffer APIs are thread-specific for certain operations. + /// + /// + public MainLoopCoordinator ( + ITimedEvents timedEvents, + Func> inputFactory, + ConcurrentQueue inputBuffer, + IInputProcessor inputProcessor, + Func outputFactory, + IMainLoop loop + ) + { + _timedEvents = timedEvents; + _inputFactory = inputFactory; + _inputBuffer = inputBuffer; + _inputProcessor = inputProcessor; + _outputFactory = outputFactory; + _loop = loop; + } + + /// + /// Starts the input loop thread in separate task (returning immediately). + /// + 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. + var waitForSemaphore = _startupSemaphore.WaitAsync (); + + // Wait for either the semaphore to be released or the input task to crash. + var 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 Exception ("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)"); + } + } + + /// + 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 = false; + + /// + 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..78557d2dae --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MouseButtonStateEx.cs @@ -0,0 +1,89 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Not to be confused with +/// +internal class MouseButtonStateEx +{ + private readonly Func _now; + private readonly TimeSpan _repeatClickThreshold; + private readonly int _buttonIdx; + private int _consecutiveClicks; + private Point _lastPosition = new Point (); + + /// + /// When the button entered its current state. + /// + public DateTime At { get; set; } + + /// + /// if the button is currently down + /// + public bool Pressed { get; set; } + + public MouseButtonStateEx (Func 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..5f896a729e --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/MouseInterpreter.cs @@ -0,0 +1,106 @@ +#nullable enable + +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +internal class MouseInterpreter +{ + /// + /// Function for returning the current time. Use in unit tests to + /// ensure repeatable tests. + /// + public Func Now { get; set; } + + /// + /// How long to wait for a second, third, fourth click after the first before giving up and + /// releasing event as a 'click' + /// + public TimeSpan RepeatedClickThreshold { get; set; } + + private readonly MouseButtonStateEx [] _buttonStates; + + public MouseInterpreter ( + Func? 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 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.Logger.LogTrace ($"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..c113596bcd --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetInput.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// Console input implementation that uses native dotnet methods e.g. . +/// +public class NetInput : ConsoleInput, INetInput +{ + private readonly NetWinVTConsole _adjustConsole; + + /// + /// Creates a new instance of the class. Implicitly sends + /// console mode settings that enable virtual input (mouse + /// reporting etc). + /// + 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; + } + + /// + protected override bool Peek () { return Console.KeyAvailable; } + + /// + protected override IEnumerable Read () + { + while (Console.KeyAvailable) + { + yield return Console.ReadKey (true); + } + } + + /// + 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..ae93c5d67b --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetInputProcessor.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// Input processor for , deals in stream +/// +public class NetInputProcessor : InputProcessor +{ + #pragma warning disable CA2211 + /// + /// Set to true to generate code in (verbose only) for test cases in NetInputProcessorTests. + /// 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. + /// + public static bool GenerateTestCasesForKeyPresses = false; + #pragma warning enable CA2211 + + /// + public NetInputProcessor (ConcurrentQueue inputBuffer) : base (inputBuffer, new NetKeyConverter()) { } + + /// + protected override void Process (ConsoleKeyInfo consoleKeyInfo) + { + foreach (Tuple released in Parser.ProcessInput (Tuple.Create (consoleKeyInfo.KeyChar, consoleKeyInfo))) + { + ProcessAfterParsing (released.Item2); + } + } + + /// + protected override void ProcessAfterParsing (ConsoleKeyInfo input) + { + // For building test cases + if (GenerateTestCasesForKeyPresses) + { + Logging.Logger.LogTrace (FormatConsoleKeyInfoForTestCase (input)); + } + + Key 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}'"; + string expectedLiteral = $"new Rune('todo')"; + + return $"yield return new object[] {{ 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} }};"; + } +} \ No newline at end of file diff --git a/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs b/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs new file mode 100644 index 0000000000..2df67e7410 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetKeyConverter.cs @@ -0,0 +1,19 @@ +namespace Terminal.Gui; + +public class NetKeyConverter : IKeyConverter +{ + /// + 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..c36f5b0e87 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NetOutput.cs @@ -0,0 +1,249 @@ +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui; + +/// +/// Implementation of that uses native dotnet +/// methods e.g. +/// +public class NetOutput : IConsoleOutput +{ + private readonly bool _isWinPlatform; + + private CursorVisibility? _cachedCursorVisibility; + + /// + /// Creates a new instance of the class. + /// + 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); + } + + /// + public void Write (string text) { Console.Write (text); } + + /// + 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; + } + + /// + 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; + } + + + /// + public void SetCursorPosition (int col, int row) { SetCursorPositionImpl (col, row); } + + private Point _lastCursorPosition = new Point (); + private bool SetCursorPositionImpl (int col, int row) + { + if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row) + { + return true; + } + + _lastCursorPosition = new Point (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; + } + + /// + 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 (); + } + + /// + 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..9abc485211 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/NotInitializedException.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui; + +/// +/// Thrown when user code attempts to access a property or perform a method +/// that is only supported after Initialization e.g. of an +/// +public class NotInitializedException : Exception +{ + /// + /// Creates a new instance of the exception indicating that the class + /// cannot be used until owner is initialized. + /// + /// Property or method name + public NotInitializedException (string memberName):base($"{memberName} cannot be accessed before Initialization") + { + } + + /// + /// Creates a new instance of the exception with the full message/inner exception. + /// + /// + /// + 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..112c7f9cc2 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/OutputBuffer.cs @@ -0,0 +1,449 @@ +#nullable enable +using System.Diagnostics; + +namespace Terminal.Gui; + +/// +/// Stores the desired output state for the whole application. This is updated during +/// draw operations before being flushed to the console as part of +/// operation +/// +public class OutputBuffer : IOutputBuffer +{ + /// + /// The contents of the application output. The driver outputs this buffer to the terminal when + /// UpdateScreen is called. + /// The format of the array is rows, columns. The first index is the row, the second index is the column. + /// + public Cell [,] Contents { get; set; } = new Cell[0, 0]; + + private Attribute _currentAttribute; + private int _cols; + private int _rows; + + /// + /// The that will be used for the next or + /// call. + /// + 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; + } + } + + /// The leftmost column in the terminal. + public virtual int Left { get; set; } = 0; + + /// + /// Gets the row last set by . and are used by + /// and to determine where to add content. + /// + public int Row { get; private set; } + + /// + /// Gets the column last set by . and are used by + /// and to determine where to add content. + /// + public int Col { get; private set; } + + /// The number of rows visible in the terminal. + public int Rows + { + get => _rows; + set + { + _rows = value; + ClearContents (); + } + } + + /// The number of columns visible in the terminal. + public int Cols + { + get => _cols; + set + { + _cols = value; + ClearContents (); + } + } + + /// The topmost row in the terminal. + public virtual int Top { get; set; } = 0; + + /// + 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? + /// Gets the location and size of the terminal screen. + internal Rectangle Screen => new (0, 0, Cols, Rows); + + private Region? _clip; + + /// + /// Gets or sets the clip rectangle that and are subject + /// to. + /// + /// The rectangle describing the of region. + 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); + } + } + } + + /// Adds the specified rune to the display at the current cursor position. + /// + /// + /// When the method returns, will be incremented by the number of columns + /// required, even if the new column value is outside of the or screen + /// dimensions defined by . + /// + /// + /// If requires more than one column, and plus the number of columns + /// needed exceeds the or screen dimensions, the default Unicode replacement character (U+FFFD) + /// will be added instead. + /// + /// + /// Rune to add. + 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++; + } + } + + /// + /// Adds the specified to the display at the current cursor position. This method is a + /// convenience method that calls with the constructor. + /// + /// Character to add. + public void AddRune (char c) { AddRune (new Rune (c)); } + + /// Adds the to the display at the cursor position. + /// + /// + /// When the method returns, will be incremented by the number of columns + /// required, unless the new column value is outside of the or screen + /// dimensions defined by . + /// + /// If requires more columns than are available, the output will be clipped. + /// + /// String. + public void AddStr (string str) + { + List runes = str.EnumerateRunes ().ToList (); + + for (var i = 0; i < runes.Count; i++) + { + AddRune (runes [i]); + } + } + + /// Clears the of the driver. + 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); + } + + /// Tests whether the specified coordinate are valid for drawing the specified Rune. + /// Used to determine if one or two columns are required. + /// The column. + /// The row. + /// + /// if the coordinate is outside the screen bounds or outside of . + /// otherwise. + /// + 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); + } + + /// + public void SetWindowSize (int cols, int rows) + { + Cols = cols; + Rows = rows; + ClearContents (); + } + + /// + 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 + }; + } + } + } + } + + /// + 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 + /// + /// Updates and to the specified column and row in . + /// Used by and to determine where to add content. + /// + /// + /// This does not move the cursor on the screen, it only updates the internal state of the driver. + /// + /// If or are negative or beyond and + /// , the method still sets those properties. + /// + /// + /// Column to move to. + /// Row to move to. + 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/V2.cd b/Terminal.Gui/ConsoleDrivers/V2/V2.cd new file mode 100644 index 0000000000..53ffa8fa60 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/V2.cd @@ -0,0 +1,459 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QIAACAAAACAEAAAAAAAAAAAkAAAAAAAAAwAAAAAAABA= + ConsoleDrivers\V2\WindowsInput.cs + + + + + + + AAAAAAAAACAEAAAAQAAAAAAgAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\NetInput.cs + + + + + + + AAAAAAAAACAEAQAAAAAAAAAgACAAAAAAAAAAAAAAAAo= + ConsoleDrivers\V2\ConsoleInput.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QQQAAAAQACABJQQAABAAAAAAACIAAAACIAEAAACAEgg= + ConsoleDrivers\V2\MainLoop.cs + + + + + + + + + + + + + + + IAAAIAEiCAIABAAAABQAAAAAABAAAQQAIQIABAAACgg= + ConsoleDrivers\V2\MainLoopCoordinator.cs + + + + + + + + + + AAQAAAAAAAAACIAAAAAAAAAAAAAgAABAAAAACBAAAAA= + ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs + + + + + + + + + + AwAAAAAAAIAAAECIBgAEQIAAAAEMRgAACAAAKABAgAA= + ConsoleDrivers\V2\OutputBuffer.cs + + + + + + + AEAAAAAAACAAAAAAAAAAAAAAAAAAQAAAMACAAAEAgAk= + ConsoleDrivers\V2\NetOutput.cs + + + + + + + AEAAABACACAAhAAAAAAAACCAAAgAQAAIMAAAAAEAgAQ= + ConsoleDrivers\V2\WindowsOutput.cs + + + + + + + + + + + + + + + + AQAkEAAAAASAiAAEAgwgAAAABAIAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\InputProcessor.cs + + + + + + + + + + + + AAAAAAAAAAAACBAAAgAAAEAAAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\NetInputProcessor.cs + + + + + + AQAAAAAAAAAACAAAAgAAAAAAAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\WindowsInputProcessor.cs + + + + + + BAAAAAAAAAgAAAAAAAAAAAAAIAAAAAAAQAAAAAAAAAA= + ConsoleDrivers\AnsiResponseParser\AnsiMouseParser.cs + + + + + + + + + + AQMgAAAAAKBAgFEIBBgAQJEAAjkaQiIAGQADKABDggQ= + ConsoleDrivers\V2\ConsoleDriverFacade.cs + + + + + + + AAQAACAAIAAAIAACAESQAAQAACGAAAAAAAAAAAAAQQA= + ConsoleDrivers\AnsiResponseParser\AnsiRequestScheduler.cs + + + + + + + + + UAiASAAAEICQALAAQAAAKAAAoAIAAABAAQIAJgAQASU= + ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs + + + + + + + + + + + + AAAABAAAAAAAAAAAAgAAAAAAACAAAAAAAAUAAAAIAAA= + ConsoleDrivers\V2\MouseInterpreter.cs + + + + + + + + + AAAAAAAAAMwAIAAAAAAAAAAAABCAAAAAAAAABAAEAAg= + ConsoleDrivers\V2\MouseButtonState.cs + + + + + + AAAAAAAAAAIAACAAAAAAAIAAAAAAAACAAAAAAAgAAAA= + ConsoleDrivers\AnsiResponseParser\StringHeld.cs + + + + + + + AAAAAAAAgAIAACAAAAAAAIAAAAAAAACAAAAAAAAAAAA= + ConsoleDrivers\AnsiResponseParser\GenericHeld.cs + + + + + + + AAAAAAAAAEAAAAAAAEAAAAACAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\AnsiEscapeSequenceRequest.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAgAAEAAAA= + ConsoleDrivers\AnsiEscapeSequence.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAgACBAAAAACBAAAAA= + ConsoleDrivers\AnsiResponseParser\AnsiResponseParser.cs + + + + + + hEK4FAgAqARIspQeBwoUgTGgACNL0AIAESLKoggBSw8= + Application\Application.cs + + + + + + AABAAAAAIAAIAgQQAAAAAQAAAAAAAAAAQAAKgAAAAAI= + Application\ApplicationImpl.cs + + + + + + + + + + QAAAAAgABAEIBgAQAAAAAQAAAAAAgAEAAAAKgIAAAgI= + ConsoleDrivers\V2\ApplicationV2.cs + + + + + + + + + 3/v2dzPLvbb/5+LOHuv1x0dem3Y57v/8c6afz2/e/Y8= + View\View.Adornments.cs + + + + + + + AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\WindowsKeyConverter.cs + + + + + + + AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\NetKeyConverter.cs + + + + + + + AAAAAQEAAAAAAAAAAAAAAAAAAAQgAAAAAAABCAAAAAE= + ConsoleDrivers\AnsiResponseParser\AnsiKeyboardParser.cs + + + + + + AAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAI= + ConsoleDrivers\V2\IConsoleInput.cs + + + + + + QAQAAAAAAAABIQQAAAAAAAAAAAAAAAACAAAAAAAAEAA= + ConsoleDrivers\V2\IMainLoop.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAMAAAAAEAAAA= + ConsoleDrivers\V2\IConsoleOutput.cs + + + + + + AQAAAAAAAIAAAEAIAAAAQIAAAAEMRgAACAAAKABAgAA= + ConsoleDrivers\V2\IOutputBuffer.cs + + + + + + AAAkAAAAAACAgAAAAAggAAAABAIAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\IInputProcessor.cs + + + + + + AAAAAAAAAAIAACAAAAAAAIAAAAAAAACAAAAAAAAAAAA= + ConsoleDrivers\AnsiResponseParser\IHeld.cs + + + + + + AAAAQAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAJAAAAAA= + ConsoleDrivers\AnsiResponseParser\IAnsiResponseParser.cs + + + + + + + + + AAAAAAAAAAAIAgQQAAAAAQAAAAAAAAAAAAAKgAAAAAI= + Application\IApplication.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQIAAAAAAAA= + ConsoleDrivers\V2\IMainLoopCoordinator.cs + + + + + + AAAAAAAAAAAAAAAEAAAAAAAAAAAACAAAAAAAAAAAAAA= + ConsoleDrivers\V2\IWindowSizeMonitor.cs + + + + + + + + + BAAAIAAAAQAAAAAQACAAAIBAAQAAAAAAAAAIgAAAAAA= + Application\ITimedEvents.cs + + + + + + AAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + ConsoleDrivers\V2\IKeyConverter.cs + + + + + + AAAAAAAAAAAAAAAAAAAACAAAAAAIAAIAAAAAAAAAAAA= + ConsoleDrivers\AnsiResponseParser\AnsiResponseParserState.cs + + + + \ 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); + + /// Invoked when the terminal's size changed. The new size of the terminal is provided. + public event EventHandler SizeChanging; + + public WindowSizeMonitor (IConsoleOutput consoleOut, IOutputBuffer outputBuffer) + { + _consoleOut = consoleOut; + _outputBuffer = outputBuffer; + } + + /// + 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, 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 () * 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 Read () + { + const int bufferSize = 1; + nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf () * bufferSize); + + try + { + ReadConsoleInput ( + _inputHandle, + pRecord, + bufferSize, + out uint numberEventsRead); + + return numberEventsRead == 0 + ? [] + : new [] { Marshal.PtrToStructure (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..af3b976f16 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsInputProcessor.cs @@ -0,0 +1,118 @@ +#nullable enable +using System.Collections.Concurrent; +using static Terminal.Gui.WindowsConsole; + +namespace Terminal.Gui; + +using InputRecord = InputRecord; + +/// +/// Input processor for , deals in stream. +/// +internal class WindowsInputProcessor : InputProcessor +{ + /// + public WindowsInputProcessor (ConcurrentQueue inputBuffer) : base (inputBuffer, new WindowsKeyConverter()) { } + + /// + 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 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; + } + } + + /// + protected override void ProcessAfterParsing (InputRecord input) + { + var key = KeyConverter.ToKey (input); + + if(key != (Key)0) + { + OnKeyDown (key!); + OnKeyUp (key!); + } + } + + private MouseEventArgs ToDriverMouse (MouseEventRecord e) + { + var result = new MouseEventArgs + { + Position = new (e.MousePosition.X, e.MousePosition.Y), + + Flags = e.ButtonState switch + { + ButtonState.NoButtonPressed => MouseFlags.ReportMousePosition, + ButtonState.Button1Pressed => MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition, + ButtonState.Button2Pressed => MouseFlags.Button2Pressed | MouseFlags.ReportMousePosition, + ButtonState.Button3Pressed => MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition, + ButtonState.Button4Pressed => MouseFlags.Button4Pressed | MouseFlags.ReportMousePosition, + ButtonState.RightmostButtonPressed => MouseFlags.Button3Pressed | MouseFlags.ReportMousePosition, + _=> MouseFlags.None + } + }; + + if (e.EventFlags == WindowsConsole.EventFlags.MouseWheeled) + { + switch ((int)e.ButtonState) + { + case int v when v > 0: + result.Flags = MouseFlags.WheeledUp; + + break; + + case int v when v < 0: + result.Flags = MouseFlags.WheeledDown; + + break; + } + } + + // TODO: Return keys too + + return result; + } +} \ No newline at end of file diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs new file mode 100644 index 0000000000..0edf381a9c --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/V2/WindowsKeyConverter.cs @@ -0,0 +1,32 @@ +#nullable enable +using Terminal.Gui.ConsoleDrivers; + +namespace Terminal.Gui; + +internal class WindowsKeyConverter : IKeyConverter +{ + /// + 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); + } + + WindowsConsole.ConsoleKeyInfoEx 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 Key (map); + } +} diff --git a/Terminal.Gui/ConsoleDrivers/V2/WindowsOutput.cs b/Terminal.Gui/ConsoleDrivers/V2/WindowsOutput.cs new file mode 100644 index 0000000000..7f8279f876 --- /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; + } + + /// + 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 = new Point (); + + /// + public void SetCursorPosition (int col, int row) + { + + if (_lastCursorPosition.X == col && _lastCursorPosition.Y == row) + { + return; + } + + _lastCursorPosition = new Point (col, row); + + SetConsoleCursorPosition (_screenBuffer, new ((short)col, (short)row)); + } + + private bool _isDisposed = false; + + /// + 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 2c6689737f..66ceff41d4 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 02d8a03ff7..34da9f24c1 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; @@ -575,7 +575,7 @@ internal void ProcessInputAfterParsing (WindowsConsole.InputRecord inputEvent) public IEnumerable ShouldReleaseParserHeldKeys () { - if (_parser.State == AnsiResponseParserState.ExpectingBracket && + if (_parser.State == AnsiResponseParserState.ExpectingEscapeSequence && DateTime.Now - _parser.StateChangedAt > EscTimeout) { return _parser.Release ().Select (o => o.Item2); @@ -620,7 +620,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) /// /// /// - public static bool operator != (Key a, Key b) { return !a!.Equals (b); } + public static bool operator != (Key a, Key? b) { return !a!.Equals (b); } /// Compares two s for less-than. /// diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index 8ef9dadbcc..6e9153a3dd 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -57,6 +57,7 @@ + @@ -79,6 +80,7 @@ + diff --git a/Terminal.Gui/View/View.Drawing.cs b/Terminal.Gui/View/View.Drawing.cs index adea35c36a..0967fb7cc9 100644 --- a/Terminal.Gui/View/View.Drawing.cs +++ b/Terminal.Gui/View/View.Drawing.cs @@ -742,7 +742,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 35de80f2b3..726e9c62a9 100644 --- a/Terminal.Gui/View/View.cs +++ b/Terminal.Gui/View/View.cs @@ -250,8 +250,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 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/UICatalog.cs b/UICatalog/UICatalog.cs index ff453b3743..7aa687d9a1 100644 --- a/UICatalog/UICatalog.cs +++ b/UICatalog/UICatalog.cs @@ -20,10 +20,13 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Serilog; using Terminal.Gui; using UICatalog.Scenarios; using static Terminal.Gui.ConfigurationManager; using Command = Terminal.Gui.Command; +using ILogger = Microsoft.Extensions.Logging.ILogger; using RuntimeEnvironment = Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment; #nullable enable @@ -123,6 +126,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) @@ -209,6 +214,26 @@ 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 + .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"); + } + private static void OpenUrl (string url) { if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) @@ -901,6 +926,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 @@ + + + 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 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..09e7cc25e6 --- /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 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.ProcessKeyboardInput (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[", 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..46516b5f7a 100644 --- a/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs +++ b/UnitTests/ConsoleDrivers/AnsiResponseParserTests.cs @@ -153,7 +153,7 @@ public static IEnumerable 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 TestInputSequencesExact_Cases () 'c', new [] { - new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingBracket,string.Empty), + new StepExpectation ('\u001b',AnsiResponseParserState.ExpectingEscapeSequence,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 ('[',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), } ]; } @@ -260,8 +260,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); @@ -299,8 +299,8 @@ public void TwoExcapesInARowWithTextBetween () // First Esc gets grabbed AssertConsumed (input, ref i); // Esc - Assert.Equal (AnsiResponseParserState.ExpectingBracket,_parser1.State); - Assert.Equal (AnsiResponseParserState.ExpectingBracket, _parser2.State); + Assert.Equal (AnsiResponseParserState.ExpectingEscapeSequence,_parser1.State); + Assert.Equal (AnsiResponseParserState.ExpectingEscapeSequence, _parser2.State); // Because next char is 'f' we do not see a bracket so release both AssertReleased (input, ref i, "\u001bf", 0,1); // f @@ -474,6 +474,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 = 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 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 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 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 [] 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 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 (); + SetupRunInputMockMethodToBlock (netInput); + var winInput = new Mock (); + SetupRunInputMockMethodToBlock (winInput); + + return new ( + ()=>netInput.Object, + Mock.Of, + () => winInput.Object, + Mock.Of); + } + + [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 (MockBehavior.Strict); + var netOutput = new Mock (MockBehavior.Strict); + var winInput = new Mock (MockBehavior.Strict); + var winOutput = new Mock (MockBehavior.Strict); + + winInput.Setup (i => i.Initialize (It.IsAny> ())) + .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 (MockBehavior.Strict); + var netOutput = new Mock (MockBehavior.Strict); + var winInput = new Mock (MockBehavior.Strict); + var winOutput = new Mock (MockBehavior.Strict); + + netInput.Setup (i => i.Initialize (It.IsAny> ())) + .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 winInput) + { + winInput.Setup (r => r.Run (It.IsAny ())) + .Callback (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 netInput) + { + netInput.Setup (r => r.Run (It.IsAny ())) + .Callback (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 (() => 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 (); + + 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 (); + SetupRunInputMockMethodToBlock (netInput); + Mock? outputMock = null; + + + var v2 = new ApplicationV2( + () => netInput.Object, + ()=> (outputMock = new Mock()).Object, + Mock.Of, + Mock.Of); + + 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 (); + + var beforeLogger = Logging.Logger; + Logging.Logger = mockLogger.Object; + + v2.Init (); + v2.Init (); + + mockLogger.Verify( + l=>l.Log (LogLevel.Error, + It.IsAny (), + It.Is ((v, t) => v.ToString () == "Init called multiple times without shutdown, ignoring."), + It.IsAny (), + It.IsAny> ()) + ,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 + { + private readonly string [] _reads; + + public FakeInput (params string [] reads ) { _reads = reads; } + + int iteration = 0; + /// + protected override bool Peek () + { + return iteration < _reads.Length; + } + + /// + protected override IEnumerable Read () + { + return _reads [iteration++]; + } + } + + [Fact] + public void Test_ThrowsIfNotInitialized () + { + var input = new FakeInput ("Fish"); + + var ex = Assert.Throws(()=>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 (); + + 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 (); + + var beforeLogger = Logging.Logger; + Logging.Logger = mockLogger.Object; + + var c = new MainLoopCoordinator (new TimedEvents (), + // Runs on a separate thread (input thread) + () => throw new Exception ("Crash on boot"), + + // Rest runs on main thread + new ConcurrentQueue (), + Mock.Of (), + ()=>Mock.Of(), + Mock.Of>()); + + // 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(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 (), + It.Is ((v, t) => v.ToString () == "Input loop crashed"), + It.IsAny (), + It.IsAny> ()) + , 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> (); + + + var mockOutput = Mock.Of (); + var mockInputProcessor = Mock.Of (); + var inputQueue = new ConcurrentQueue (); + var timedEvents = new TimedEvents (); + + var mainLoop = new MainLoop (); + mainLoop.Initialize (timedEvents, + inputQueue, + mockInputProcessor, + mockOutput + ); + + var c = new MainLoopCoordinator (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 (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 (); + + Assert.Throws (() => m.TimedEvents); + Assert.Throws (() => m.InputBuffer); + Assert.Throws (() => m.InputProcessor); + Assert.Throws (() => m.Out); + Assert.Throws (() => m.AnsiRequestScheduler); + Assert.Throws (() => m.WindowSizeMonitor); + + m.Initialize (new TimedEvents (), + new ConcurrentQueue (), + Mock.Of (), + Mock.Of()); + + 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 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 SequenceTests () + { + yield return new object [] + { + new List + { + new() { Flags = MouseFlags.Button1Pressed }, + new() + }, + null, + MouseFlags.Button1Clicked + }; + + yield return new object [] + { + new List + { + new() { Flags = MouseFlags.Button1Pressed }, + new(), + new() { Flags = MouseFlags.Button1Pressed }, + new() + }, + null, + MouseFlags.Button1Clicked, + null, + MouseFlags.Button1DoubleClicked + }; + + yield return new object [] + { + new List + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 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 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 (); + + 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 ups = new List (); + List downs = new List (); + + 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 (); + + var queue = new Queue(new []{ + new Size (30, 20), + new Size (20, 20) + + }); + + consoleOutput.Setup (m => m.GetWindowSize ()) + .Returns (queue.Dequeue); + + var outputBuffer = Mock.Of (); + + var monitor = new WindowSizeMonitor (consoleOutput.Object, outputBuffer); + + var result = new List (); + 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 (); + + var queue = new Queue (new []{ + new Size (30, 20), + new Size (30, 20), + }); + + consoleOutput.Setup (m => m.GetWindowSize ()) + .Returns (queue.Dequeue); + + var outputBuffer = Mock.Of (); + + var monitor = new WindowSizeMonitor (consoleOutput.Object, outputBuffer); + + var result = new List (); + 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..a9be501289 --- /dev/null +++ b/UnitTests/ConsoleDrivers/V2/WindowsInputProcessorTests.cs @@ -0,0 +1,186 @@ +using System.Collections.Concurrent; +using Terminal.Gui.ConsoleDrivers; +using InputRecord = Terminal.Gui.WindowsConsole.InputRecord; + +namespace UnitTests.ConsoleDrivers.V2; +public class WindowsInputProcessorTests +{ + + [Fact] + public void Test_ProcessQueue_CapitalHLowerE () + { + var queue = new ConcurrentQueue (); + + 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 ups = new List (); + List downs = new List (); + + 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 (); + + 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 mouseEvents = new List (); + + 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 (); + + 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 mouseEvents = new List (); + + 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 (); + + 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 mouseEvents = new List (); + + 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)); + } +} + 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 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. /// [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 /// [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 (); }