From 72ef5d4d649bf4b78d5d57211b692df241a1f100 Mon Sep 17 00:00:00 2001 From: Evgeniy Nikulov Date: Wed, 26 Aug 2026 18:04:48 +0300 Subject: [PATCH] Sync WinUI terminal integration from sftp-explorer --- .gitignore | 3 + Directory.Build.targets | 4 +- Microsoft.Terminal.WinUI3/HwndHost.cs | 269 +++++++- .../ITerminalConnection.cs | 43 ++ .../LICENSE-EasyWindowsTerminalControl | 276 ++++++++ .../LICENSE-WindowsTerminal | 21 + .../Microsoft.Terminal.WinUI3.csproj | 169 ++--- .../NOTICE-WindowsTerminal.md | 535 +++++++++++++++ Microsoft.Terminal.WinUI3/NativeMethods.cs | 318 +++++++++ Microsoft.Terminal.WinUI3/NativeMethods.txt | 3 +- .../NativeWindowPositionUpdatePolicy.cs | 13 + .../NativeWindowResizeOrder.cs | 16 + Microsoft.Terminal.WinUI3/README.md | 15 + .../TerminalConnectionBinding.cs | 32 + .../TerminalConnectionLifecycle.cs | 47 ++ .../TerminalContainer.cs | 647 ++++++++++++++++-- .../TerminalControl.xaml | 53 +- .../TerminalControl.xaml.cs | 175 ++++- .../TerminalOutputEventArgs.cs | 29 + .../TerminalRenderBuffer.cs | 37 + Microsoft.Terminal.WinUI3/TerminalTheme.cs | 84 +++ .../TerminalThemeDefaults.cs | 22 + Microsoft.Terminal.WinUI3/UWPHelpers.cs | 12 +- ...crosoft.Terminal.WinUI3.Unofficial.targets | 6 +- NuGet.config | 6 +- 25 files changed, 2567 insertions(+), 268 deletions(-) create mode 100644 .gitignore create mode 100644 Microsoft.Terminal.WinUI3/ITerminalConnection.cs create mode 100644 Microsoft.Terminal.WinUI3/LICENSE-EasyWindowsTerminalControl create mode 100644 Microsoft.Terminal.WinUI3/LICENSE-WindowsTerminal create mode 100644 Microsoft.Terminal.WinUI3/NOTICE-WindowsTerminal.md create mode 100644 Microsoft.Terminal.WinUI3/NativeMethods.cs create mode 100644 Microsoft.Terminal.WinUI3/NativeWindowPositionUpdatePolicy.cs create mode 100644 Microsoft.Terminal.WinUI3/NativeWindowResizeOrder.cs create mode 100644 Microsoft.Terminal.WinUI3/README.md create mode 100644 Microsoft.Terminal.WinUI3/TerminalConnectionBinding.cs create mode 100644 Microsoft.Terminal.WinUI3/TerminalConnectionLifecycle.cs create mode 100644 Microsoft.Terminal.WinUI3/TerminalOutputEventArgs.cs create mode 100644 Microsoft.Terminal.WinUI3/TerminalRenderBuffer.cs create mode 100644 Microsoft.Terminal.WinUI3/TerminalTheme.cs create mode 100644 Microsoft.Terminal.WinUI3/TerminalThemeDefaults.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..412aede --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/bin/ +**/obj/ +/Publish/ diff --git a/Directory.Build.targets b/Directory.Build.targets index cbf11f5..0a6e040 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -4,8 +4,8 @@ false false - true - true + true + true false diff --git a/Microsoft.Terminal.WinUI3/HwndHost.cs b/Microsoft.Terminal.WinUI3/HwndHost.cs index 0a3f2ad..ac0cde0 100644 --- a/Microsoft.Terminal.WinUI3/HwndHost.cs +++ b/Microsoft.Terminal.WinUI3/HwndHost.cs @@ -26,6 +26,23 @@ namespace System.Windows.Interop { /// A mostly complete hackish reimplementation of the WPF HwndHost, draw code specifically removed as Terminal did not need but can be ported /// internal abstract class HwndHost : FrameworkElement, IDisposable, IKeyboardInputSink { + [System.Runtime.InteropServices.DllImport("gdi32.dll", SetLastError = true)] + private static extern IntPtr CreateRectRgn(int left, int top, int right, int bottom); + + [System.Runtime.InteropServices.DllImport("gdi32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool DeleteObject(IntPtr hObject); + + [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)] + private static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, + [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] bool redraw); + + private double? _leftOverlayBoundaryInXamlRootDips; + private bool _hasAppliedWindowRegion; + private int _lastRegionLeft = -1; + private int _lastRegionWidth = -1; + private int _lastRegionHeight = -1; + static HwndHost() { } @@ -67,12 +84,26 @@ public IntPtr Handle { if (_hwnd != HWND.Null) { if (!PInvoke.IsWindow(_hwnd)) { _hwnd = HWND.Null; - } - } - + } +} return _hwnd; } } + + internal void SetLeftOverlayBoundary(double? boundaryInXamlRootDips) { + if (_leftOverlayBoundaryInXamlRootDips == boundaryInXamlRootDips) { + return; + } + + _leftOverlayBoundaryInXamlRootDips = boundaryInXamlRootDips; + _lastRegionLeft = -1; + _lastRegionWidth = -1; + _lastRegionHeight = -1; + + if (!_hwnd.IsNull) { + QueueWindowPositionUpdate(); + } + } protected HWND XAMLParentWindow { get { if (_XAMLParentWindow.IsNull) @@ -149,6 +180,7 @@ private void xamlParentWndProc(object sender, WindowMessageEventArgs e) { protected WindowMessageMonitor xamlParentMessageMonitor; protected WindowMessageMonitor _hwndSubclassHook; + private long? _visibilityChangedToken; /// /// An event that is notified of all unhandled messages received /// by the hosted window. @@ -177,7 +209,8 @@ public event DpiChangedEventHandler DpiChanged { /// protected virtual void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) { //RaiseEvent(new DpiChangedEventArgs(oldDpi, newDpi, HwndHost.DpiChangedEvent, this)); - UpdateWindowPos(); + InvalidateWindowPositionCache(); + QueueWindowPositionUpdate(); } @@ -422,12 +455,12 @@ public void UpdateWindowPos() { Rect GetBoundsRelativeToXAMLWindowRasterScaled() { if (this.Parent == null || XAMLParentWindow.IsNull) throw new Exception("We are not part of the UI or the parent is not yet set"); - var Pt = GetTransformToXamlRootContent().TransformPoint( - new Point(0, 0) - ); + var point = GetTransformToXamlRootContent().TransformPoint(new Point(0, 0)); + var rasterizationScale = this.XamlRoot.RasterizationScale; + point = new Point(point.X * rasterizationScale, point.Y * rasterizationScale); var toSize = ActualSize.ToSize(); - toSize = new Size(toSize.Width * this.XamlRoot.RasterizationScale, toSize.Height * this.XamlRoot.RasterizationScale); - return AdjustRectForDpi(new Rect(Pt, toSize)); + toSize = new Size(toSize.Width * rasterizationScale, toSize.Height * rasterizationScale); + return AdjustRectForDpi(new Rect(point, toSize)); } /// @@ -455,7 +488,7 @@ private Rect AdjustRectForDpi(Rect rcRect) { return rcRect; double dpiRatio = DpiParentToChildRatio; - return new(rcRect.Left / dpiRatio, rcRect.Top / dpiRatio, rcRect.Right / dpiRatio, rcRect.Bottom / dpiRatio); + return new(rcRect.X / dpiRatio, rcRect.Y / dpiRatio, rcRect.Width / dpiRatio, rcRect.Height / dpiRatio); } /// @@ -481,12 +514,28 @@ protected virtual void Dispose(bool disposing) { // Verify the thread has access to the context. #pragma warning suppress 6519 + this.SizeChanged -= OnSizeChanged; + this.PreviewKeyDown -= OnKeyDown; + this.PreviewKeyUp -= OnKeyUp; + if (_visibilityChangedToken is long visibilityChangedToken) { + this.UnregisterPropertyChangedCallback(UIElement.VisibilityProperty, visibilityChangedToken); + _visibilityChangedToken = null; + } + + if (xamlParentMessageMonitor != null) { + xamlParentMessageMonitor.WindowMessageReceived -= xamlParentWndProc; + xamlParentMessageMonitor.Dispose(); + xamlParentMessageMonitor = null; + _XAMLParentWindow = HWND.Null; + } + // Remove our subclass. Even if this fails, it will be forcably removed // when the window is destroyed. if (_hwndSubclassHook != null) { // Check if it is trusted (WebOC and AddInHost), call CriticalDetach to avoid the Demand. + _hwndSubclassHook.WindowMessageReceived -= SubclassWndProc; _hwndSubclassHook.Dispose(); _hwndSubclassHook = null; @@ -537,6 +586,20 @@ private void OnDispatcherShutdown(object sender, object e) { /// protected abstract void DestroyWindowCore(HWND hwnd); + /// + /// Called when the hosted HWND is destroyed outside . + /// Implementations can invalidate native state without destroying the HWND + /// reentrantly from inside WM_NCDESTROY. + /// + protected virtual void OnHostedWindowDestroyed(HWND hwnd) { + } + + /// + /// Called after the hosted HWND has accepted its XAML layout rectangle. + /// + protected virtual void OnHostedWindowPositionApplied(int width, int height) { + } + /// @@ -550,9 +613,17 @@ protected virtual void WndProc(WindowMessageEventArgs e) { var msg = e.Message; switch ((WindowsMessages)e.Message.MessageId) { - case WindowsMessages.NCDESTROY: + case WindowsMessages.NCDESTROY: { + var destroyedHwnd = _hwnd == HWND.Null ? new HWND(msg.Hwnd) : _hwnd; _hwnd = HWND.Null; + InvalidateWindowPositionCache(); + _hasAppliedWindowRegion = false; + _lastRegionLeft = -1; + _lastRegionWidth = -1; + _lastRegionHeight = -1; + OnHostedWindowDestroyed(destroyedHwnd); break; + } // When layout happens, we first calculate the right size/location then call SetWindowPos. // We only allow the changes that are coming from Avalon layout. The hwnd is not allowed to change by itself. @@ -641,21 +712,112 @@ private IntPtr OnWmGetObject(IntPtr wparam, IntPtr lparam) { /// /// protected virtual void OnWindowPositionChanged(Rect rcBoundingBox) { - if (_isDisposed) { + if (_isDisposed || _isUpdatingWindowPosition || _hwnd == HWND.Null || !PInvoke.IsWindow(_hwnd)) { return; } + _isUpdatingWindowPosition = true; + try { + var x = (int)rcBoundingBox.X; + var y = (int)rcBoundingBox.Y; + var width = Math.Max(0, (int)rcBoundingBox.Width); + var height = Math.Max(0, (int)rcBoundingBox.Height); + if (_hasLastWindowPosition && + _lastWindowX == x && + _lastWindowY == y && + _lastWindowWidth == width && + _lastWindowHeight == height) { + ApplyWindowRegion(rcBoundingBox); + return; + } + + // HWND_BOTTOM controls the z-order only among HWND siblings. XAML siblings are + // compositor content, so they still suffer from HwndHost airspace. Keep the + // renderer at the bottom of the HWND sibling order, then clip the portion that + // is covered by the left-side XAML overlay. + NativeWindowResizeOrder.Apply( + () => PInvoke.SetWindowPos(_hwnd, + new HWND(1), + x, + y, + width, + height, + SET_WINDOW_POS_FLAGS.SWP_ASYNCWINDOWPOS + | SET_WINDOW_POS_FLAGS.SWP_NOCOPYBITS + | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE), + () => { + _hasLastWindowPosition = true; + _lastWindowX = x; + _lastWindowY = y; + _lastWindowWidth = width; + _lastWindowHeight = height; + OnHostedWindowPositionApplied(width, height); + }); + + ApplyWindowRegion(rcBoundingBox); + } finally { + _isUpdatingWindowPosition = false; + } + } - PInvoke.SetWindowPos(_hwnd, - HWND.Null, - (int)rcBoundingBox.X, - (int)rcBoundingBox.Y, - (int)rcBoundingBox.Width, - (int)rcBoundingBox.Height, - SET_WINDOW_POS_FLAGS.SWP_ASYNCWINDOWPOS - | SET_WINDOW_POS_FLAGS.SWP_NOZORDER - | SET_WINDOW_POS_FLAGS.SWP_NOCOPYBITS - | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE); + private void InvalidateWindowPositionCache() { + _hasLastWindowPosition = false; + } + + private void ApplyWindowRegion(Rect rcBoundingBox) { + if (_hwnd.IsNull) { + return; + } + + var width = Math.Max(0, (int)Math.Ceiling(rcBoundingBox.Width)); + var height = Math.Max(0, (int)Math.Ceiling(rcBoundingBox.Height)); + var clipLeft = 0; + + if (_leftOverlayBoundaryInXamlRootDips is double boundary && XamlRoot != null) { + var boundaryInChildPixels = boundary * XamlRoot.RasterizationScale; + if (_hasDpiAwarenessContextTransition) { + boundaryInChildPixels /= DpiParentToChildRatio; + } + + clipLeft = Math.Clamp( + (int)Math.Ceiling(boundaryInChildPixels - rcBoundingBox.X), + 0, + width); + } + + if (clipLeft <= 0) { + if (_hasAppliedWindowRegion) { + SetWindowRgn(_hwnd.Value, IntPtr.Zero, true); + _hasAppliedWindowRegion = false; + } + _lastRegionLeft = 0; + _lastRegionWidth = width; + _lastRegionHeight = height; + return; + } + + if (_hasAppliedWindowRegion && + _lastRegionLeft == clipLeft && + _lastRegionWidth == width && + _lastRegionHeight == height) { + return; + } + + var region = CreateRectRgn(clipLeft, 0, width, height); + if (region == IntPtr.Zero) { + return; + } + + // After a successful SetWindowRgn call USER32 owns the HRGN. + if (SetWindowRgn(_hwnd.Value, region, true) == 0) { + DeleteObject(region); + return; + } + + _hasAppliedWindowRegion = true; + _lastRegionLeft = clipLeft; + _lastRegionWidth = width; + _lastRegionHeight = height; } /// @@ -703,10 +865,10 @@ private void Initialize(bool fTrusted) { - this.LayoutUpdated += (_, _) => UpdateWindowPos(); + this.SizeChanged += OnSizeChanged; this.PreviewKeyDown += OnKeyDown; this.PreviewKeyUp += OnKeyUp; - this.RegisterPropertyChangedCallback(UIElement.VisibilityProperty, OnVisibleChanged); + _visibilityChangedToken = this.RegisterPropertyChangedCallback(UIElement.VisibilityProperty, OnVisibleChanged); } @@ -743,8 +905,42 @@ private void OnSourceChanged(object sender) { BuildOrReparentWindow(); } - private void OnLayoutUpdated(object sender, object e) { - UpdateWindowPos(); + private void OnSizeChanged(object sender, SizeChangedEventArgs e) { + var hasValidSize = e.NewSize.Width > 0 && e.NewSize.Height > 0; + if (!NativeWindowPositionUpdatePolicy.ShouldQueue( + _isDisposed, + _isUpdatingWindowPosition, + hasValidSize, + _lastXamlSizeForWindowPosition == e.NewSize)) { + return; + } + + _lastXamlSizeForWindowPosition = e.NewSize; + QueueWindowPositionUpdate(); + } + + private void QueueWindowPositionUpdate() { + if (_isDisposed || _windowPositionUpdateQueued) { + return; + } + + _windowPositionUpdateQueued = true; + try { + if (DispatcherQueue.TryEnqueue(() => { + _windowPositionUpdateQueued = false; + if (_isDisposed) { + return; + } + + UpdateWindowPos(); + })) { + return; + } + } catch (Exception ex) { + Debug.WriteLine($"Unable to queue hosted window position update: {ex}"); + } + + _windowPositionUpdateQueued = false; } private void OnEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) { @@ -774,7 +970,8 @@ private void OnVisibleChanged(DependencyObject sender, DependencyProperty dp) { PInvoke.ShowWindowAsync(_hwnd, ActivateOnShow ? SHOW_WINDOW_CMD.SW_SHOW : SHOW_WINDOW_CMD.SW_SHOWNA); else PInvoke.ShowWindowAsync(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); - UpdateWindowPos(); + + QueueWindowPositionUpdate(); } // This routine handles the following cases: @@ -806,13 +1003,13 @@ private void BuildOrReparentWindow() { // We now have a parent window, so we can create the child // window. BuildWindow(XAMLParentWindow); - this.LayoutUpdated += OnLayoutUpdated; //this.IsEnabledChanged += _handlerEnabledChanged; //this.propertych += _handlerVisibleChanged; } else if (XAMLParentWindow != PInvoke.GetParent(_hwnd)) { // We have a different parent window. Just reparent the // child window under the new parent window. PInvoke.SetParent(_hwnd, XAMLParentWindow); + InvalidateWindowPositionCache(); } } else if (Handle != IntPtr.Zero) { // Reparent the window to notification-only window provided by SystemResources @@ -851,7 +1048,12 @@ private unsafe void BuildWindow(HWND hwndParent) { DemandIfUntrusted(); // Allow the derived class to build our HWND. + InvalidateWindowPositionCache(); _hwnd = BuildWindowCore(hwndParent); + _hasAppliedWindowRegion = false; + _lastRegionLeft = -1; + _lastRegionWidth = -1; + _lastRegionHeight = -1; if (_hwnd == IntPtr.Zero || !PInvoke.IsWindow(_hwnd)) { throw new InvalidOperationException("ChildWindowNotCreated"); } @@ -912,7 +1114,7 @@ private unsafe void BuildWindow(HWND hwndParent) { private void DispatcherInvoke(Action action, Microsoft.UI.Dispatching.DispatcherQueuePriority priority = Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal) => UWPHelpers.Enqueue(this.DispatcherQueue, action, priority); - private bool CheckAccess() => false; + private bool CheckAccess() => this.DispatcherQueue.HasThreadAccess; private void DestroyWindow() { // Destroy the window if we are hosting one. if (Handle == IntPtr.Zero) @@ -931,6 +1133,7 @@ private void DestroyWindow() { var hwnd = _hwnd; _hwnd = HWND.Null; + InvalidateWindowPositionCache(); DestroyWindowCore(hwnd); } @@ -954,6 +1157,14 @@ private void SubclassWndProc(object sender, WindowMessageEventArgs e) { private HWND _hwnd; private Size _desiredSize; + private bool _isUpdatingWindowPosition; + private bool _windowPositionUpdateQueued; + private Size _lastXamlSizeForWindowPosition; + private bool _hasLastWindowPosition; + private int _lastWindowX; + private int _lastWindowY; + private int _lastWindowWidth; + private int _lastWindowHeight; /// /// True when the parent of and diff --git a/Microsoft.Terminal.WinUI3/ITerminalConnection.cs b/Microsoft.Terminal.WinUI3/ITerminalConnection.cs new file mode 100644 index 0000000..acef865 --- /dev/null +++ b/Microsoft.Terminal.WinUI3/ITerminalConnection.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Terminal.Wpf +{ + using System; + + /// + /// Represents a connection to a terminal backend, generally a pty interface. + /// + public interface ITerminalConnection + { + /// + /// Event that should be triggered when the terminal backend has new data for the terminal control. + /// + event EventHandler TerminalOutput; + + /// + /// Inform the backend that the terminal control is ready to start sending and receiving data. + /// + void Start(); + + /// + /// Write user input to the backend. + /// + /// The data to be written to the terminal backend. + void WriteInput(string data); + + /// + /// Resize the terminal backend. + /// + /// The number of rows to resize to. + /// The number of columns to resize to. + void Resize(uint rows, uint columns); + + /// + /// Shut down the terminal backend process. + /// + void Close(); + } +} diff --git a/Microsoft.Terminal.WinUI3/LICENSE-EasyWindowsTerminalControl b/Microsoft.Terminal.WinUI3/LICENSE-EasyWindowsTerminalControl new file mode 100644 index 0000000..e11e8fa --- /dev/null +++ b/Microsoft.Terminal.WinUI3/LICENSE-EasyWindowsTerminalControl @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +