diff --git a/AGENTS.md b/AGENTS.md index c5fabe7..2f85d55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ The same package bundles Roslyn tooling: an incremental source generator that em * Model a temporary state change as an `IDisposable` returned from a `Begin...Scope()` factory that reverses it on `Dispose`. * Shared static state is thread-safe. * Reflection and native interop stay non-public in `Internal/`. +* CsWin32 generates the Win32 interop from the `NativeMethods.txt` of each project. A new Win32 API goes into that file, never into a hand-written `DllImport` or `LibraryImport`. * Never break the public surface. Deprecate a renamed member with `[Obsolete]` with a JetBrains `[CodeTemplate]` auto-conversion; the obsolete member forwards to the replacement and never throws or changes behavior. * Mark a member Revit invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`. * The Roslyn tooling is public surface. It targets `netstandard2.0`, dual-targets Roslyn (the default with the `.Roslyn414` twins), uses stable `RVTTK####` diagnostic ids, and needs an `AnalyzerReleases.Unshipped.md` entry per new or changed rule. Never rename a generated member or renumber a shipped id. diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ed88e..e590cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,16 @@ ### External Events -- External events always run as your add-in, even when created on a background thread or in a modeless window. Inside the handler, `ActiveAddInId` returns your add-in, and Extensible Storage schemas with vendor access accept the changes. Previously, Revit ran such a handler without an add-in. -- `ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references. -- New `RegisterAddInAssembly()` method for the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package. Call it in `OnStartup`: +#### External events run as your add-in from any thread + +An external event created on a background thread or in a modeless window used to run its handler without an add-in. +Inside such a handler, `UIApplication.ActiveAddInId` returned no add-in, and Extensible Storage refused the changes to schemas with vendor write access. + +Now the handler always runs as your add-in: `ActiveAddInId` returns its ID, and vendor schemas accept the changes. +`ExternalApplication` and `ExternalCommand` set this up automatically, for the add-in project and every project it references. + +The new `RegisterAddInAssembly()` method covers the remaining cases: an `IExternalApplication` without the Toolkit base class, an assembly loaded at run time, or a library from a NuGet package. +Call it in `OnStartup`: ```c# public Result OnStartup(UIControlledApplication application) @@ -14,6 +21,27 @@ public Result OnStartup(UIControlledApplication application) } ``` +### Context + +#### No more frozen Revit interface after loading older families + +It's a long-standing Revit issue known from the forums: an add-in loads a family saved in an earlier Revit version, and the Revit interface stops updating. +The ribbon, the dockable panes, and the add-in windows still react to clicks, but nothing redraws until you resize a window. + +The cause is the modal loop of the Model Upgrade progress dialog: it swallows the message WPF waits for before it draws the next frame. + +The new `RevitContext.BeginModalLoopCompatibilityScope()` fixes it. +Wrap the calls that load or open older files, and the interface redraws as soon as the scope ends: + +```c# +using (RevitContext.BeginModalLoopCompatibilityScope()) +{ + document.LoadFamily(fileName, out var family); +} +``` + +The scope covers `Document.LoadFamily`, `Application.NewFamilyDocument`, and `Application.OpenDocumentFile`, and costs a single window message when it ends. + # 2027.0.3 ### External Events diff --git a/Directory.Packages.props b/Directory.Packages.props index 71811c7..7cd2b8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,9 @@ + + + diff --git a/README.md b/README.md index a0c1f3c..e8f93ba 100644 --- a/README.md +++ b/README.md @@ -210,15 +210,19 @@ A raise that Revit refuses with `Denied` or `TimedOut` never runs. `Raise(argument)` returns that result to the caller. The `RaiseAsync()` task fails with an `InvalidOperationException`. -Inside the event handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, even when the event is created on a background thread or in a modeless window. -Extensible Storage schemas with vendor write access accept the changes of the handler. -`ExternalApplication` and `ExternalCommand` set this up automatically. -Without them, or for a library from a NuGet package or loaded at run time, register the assembly in `OnStartup`: +**Running as your add-in from any thread** + +An external event created on a background thread or in a modeless window runs its handler as your add-in. +Inside the handler, `UIApplication.ActiveAddInId` returns the ID of your add-in, and Extensible Storage schemas with vendor write access accept the changes. +`ExternalApplication` and `ExternalCommand` register the add-in automatically. +Without them, or for a library loaded at run time or from a NuGet package, register the assembly in `OnStartup`: ```c# application.RegisterAddInAssembly(typeof(Application).Assembly); ``` +The method registers the project references of the assembly too. + #### ExternalEvent A synchronous external event that queues the handler via the Revit external event mechanism. @@ -843,6 +847,21 @@ using (RevitContext.BeginDialogSuppressionScope(args => } ``` +**Loading families from earlier Revit versions** + +After the API loads a family or creates one from a template saved in an earlier Revit version, the ribbon, the dockable panes, and the add-in windows stop redrawing. +They respond to clicks but show no change until a window is resized. +Wrap the calls in `BeginModalLoopCompatibilityScope()` on the Revit thread: + +```C# +using (RevitContext.BeginModalLoopCompatibilityScope()) +{ + document.LoadFamily(fileName, out var family); +} +``` + +The interface redraws when the scope is disposed. + ### Options The Toolkit provides implementation of various Revit interfaces. diff --git a/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs b/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs new file mode 100644 index 0000000..e2a24b7 --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/Internal/MediaContextNotificationWindow.cs @@ -0,0 +1,57 @@ +using Windows.Win32; +using Windows.Win32.Foundation; + +namespace Nice3point.Revit.Toolkit.Internal; + +/// +/// Provides access to the window WPF creates on each UI thread to receive the notifications of its render thread. +/// +/// +/// The window title and the registered message name are internal to WPF and have the same values in .NET Framework and .NET. +/// +internal static class MediaContextNotificationWindow +{ + private const string WindowName = "MediaContextNotificationWindow"; + private static readonly uint ChannelNotifyMessage = PInvoke.RegisterWindowMessage("MilChannelNotify"); + + [ThreadStatic] private static HWND _window; + + /// + /// Posts the channel notification to the notification window of the current thread. + /// + /// + /// The method caches the window handle per thread after the first search. + /// A thread without a WPF dispatcher has no notification window, and the method posts nothing. + /// + public static void PostChannelNotification() + { + if (!_window.IsNull && PInvoke.PostMessage(_window, ChannelNotifyMessage, default, default)) + { + return; + } + + _window = FindThreadWindow(); + if (!_window.IsNull) + { + PInvoke.PostMessage(_window, ChannelNotifyMessage, default, default); + } + } + + /// + /// Finds the notification window of the current thread among the top-level windows of the desktop. + /// + /// The window handle, or a null handle when the thread has no notification window. + private static unsafe HWND FindThreadWindow() + { + var threadId = PInvoke.GetCurrentThreadId(); + var window = HWND.Null; + while (true) + { + window = PInvoke.FindWindowEx(HWND.Null, window, null, WindowName); + if (window.IsNull || PInvoke.GetWindowThreadProcessId(window, null) == threadId) + { + return window; + } + } + } +} diff --git a/source/Nice3point.Revit.Toolkit/NativeMethods.txt b/source/Nice3point.Revit.Toolkit/NativeMethods.txt new file mode 100644 index 0000000..98a664f --- /dev/null +++ b/source/Nice3point.Revit.Toolkit/NativeMethods.txt @@ -0,0 +1,5 @@ +FindWindowEx +GetCurrentThreadId +GetWindowThreadProcessId +PostMessage +RegisterWindowMessage diff --git a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj index f0eb7b5..f83ae1f 100644 --- a/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj +++ b/source/Nice3point.Revit.Toolkit/Nice3point.Revit.Toolkit.csproj @@ -6,6 +6,7 @@ $(DefineConstants);JETBRAINS_ANNOTATIONS Debug.R20;Debug.R21;Debug.R22;Debug.R23;Debug.R24;Debug.R25;Debug.R26;Debug.R27 $(Configurations);Release.R20;Release.R21;Release.R22;Release.R23;Release.R24;Release.R25;Release.R26;Release.R27 + $(NoWarn);PInvoke009 @@ -36,6 +37,7 @@ + diff --git a/source/Nice3point.Revit.Toolkit/RevitContext.cs b/source/Nice3point.Revit.Toolkit/RevitContext.cs index 3f4d160..de4a9d9 100644 --- a/source/Nice3point.Revit.Toolkit/RevitContext.cs +++ b/source/Nice3point.Revit.Toolkit/RevitContext.cs @@ -3,9 +3,7 @@ using System.Windows; using Autodesk.Revit.UI; using Autodesk.Revit.UI.Events; -#if REVIT2027_OR_GREATER using Nice3point.Revit.Toolkit.Internal; -#endif namespace Nice3point.Revit.Toolkit; @@ -353,6 +351,39 @@ public static IDisposable BeginDialogSuppressionScope(Action + /// Begins a scope that redraws the Revit user interface after the API upgrades a family from an earlier Revit version. + /// + /// A disposable scope. Call Dispose or use 'using' statement to redraw the user interface. + /// + /// + /// , , + /// and upgrade a family or a family template saved in an earlier Revit version, + /// and Revit runs the modal loop of the Model Upgrade progress dialog. + /// The modal loop discards the notification WPF waits for before it draws the next frame. + /// After the upgrade, the ribbon, the dockable panes, and the WPF windows of the Revit thread respond to input but show no change until a window changes its size. + /// The model views are not affected. + /// + /// + /// Disposing the scope posts the notification again, and the user interface redraws. + /// Begin the scope on the Revit thread. + /// + /// This method is thread-safe. + /// + /// + /// + /// using (RevitContext.BeginModalLoopCompatibilityScope()) + /// { + /// document.LoadFamily(fileName, out var family); + /// } + /// // The user interface redraws + /// + /// + public static IDisposable BeginModalLoopCompatibilityScope() + { + return ModalLoopCompatibilityScope.Instance; + } + internal static IDisposable BeginApiContextScope() { return new ApiContextScope(IncrementConstructorPointer, IncrementDestructorPointer); @@ -431,6 +462,22 @@ public void Dispose() } } + /// + /// Represents a scope that posts the frame notification of WPF to the current thread on every dispose. + /// + /// + /// WPF returns at once on a repeated notification, and every call of returns the same instance. + /// + private sealed class ModalLoopCompatibilityScope : IDisposable + { + public static readonly ModalLoopCompatibilityScope Instance = new(); + + public void Dispose() + { + MediaContextNotificationWindow.PostChannelNotification(); + } + } + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr IncrementCtor(IntPtr self); diff --git a/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs b/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs new file mode 100644 index 0000000..d2120ad --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/ColorWindow.cs @@ -0,0 +1,159 @@ +using System.Drawing; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.Storage.Xps; +using Brushes = System.Windows.Media.Brushes; +using Color = System.Windows.Media.Color; +using HorizontalAlignment = System.Windows.HorizontalAlignment; +using Rectangle = System.Windows.Shapes.Rectangle; + +namespace Nice3point.Revit.Toolkit.Tests; + +/// +/// Represents a WPF window of the test thread filled with one color, outside the taskbar, with one column of pixels on the screen. +/// +/// +/// Windows composes no frame for a window entirely outside the screen. +/// The color the window shows is the color of the frame Windows composes for it, the last frame WPF presented. +/// A square in its corner pulses on every frame, as the ribbon and the panes of Revit draw while Revit works: Revit discards the frame notification only while WPF draws. +/// +internal sealed class ColorWindow : IDisposable +{ + private const double WindowSize = 40; + private const double VisibleWidth = 1; + private const double PulseSize = 4; + private const double PulseMinimumOpacity = 0.2; + private const PRINT_WINDOW_FLAGS RenderFullContent = (PRINT_WINDOW_FLAGS)2; + + private static readonly TimeSpan DrawTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan PulseDuration = TimeSpan.FromMilliseconds(500); + + private readonly Window _window; + private readonly HWND _handle; + + private ColorWindow(Color color) + { + _window = new Window + { + Width = WindowSize, + Height = WindowSize, + Left = SystemParameters.VirtualScreenLeft + VisibleWidth - WindowSize, + Top = SystemParameters.VirtualScreenTop, + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + ShowInTaskbar = false, + ShowActivated = false, + Background = new SolidColorBrush(color) + }; + + var pulse = new Rectangle + { + Width = PulseSize, + Height = PulseSize, + Fill = Brushes.Black, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top + }; + + pulse.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(PulseMinimumOpacity, PulseDuration) + { + AutoReverse = true, + RepeatBehavior = RepeatBehavior.Forever + }); + + _window.Content = pulse; + _window.Show(); + _handle = new HWND(new WindowInteropHelper(_window).Handle); + } + + /// + /// Shows the window and waits until it shows the color. + /// + /// The color of the window. + /// A used to cancel the wait. + /// + /// A task that represents the asynchronous show operation. + /// The task result contains the shown window. + /// + /// The window shows another color when the time to draw a frame runs out. + public static async Task ShowAsync(Color color, CancellationToken cancellationToken) + { + var window = new ColorWindow(color); + var drawnColor = await window.WaitForDrawnColorAsync(color, cancellationToken); + if (drawnColor != color) + { + window.Dispose(); + throw new InvalidOperationException($"The window shows {drawnColor} in place of {color}."); + } + + return window; + } + + /// + /// Fills the window with the color. + /// + /// The color of the window. + public void Fill(Color color) + { + _window.Background = new SolidColorBrush(color); + } + + /// + /// Waits until the window shows the expected color, or until the time to draw a frame runs out. + /// + /// The color to wait for. + /// A used to cancel the wait. + /// + /// A task that represents the asynchronous wait operation. + /// The task result contains the color the window shows. + /// + public async Task WaitForDrawnColorAsync(Color expectedColor, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow + DrawTimeout; + var drawnColor = ReadDrawnColor(); + while (drawnColor != expectedColor && DateTime.UtcNow < deadline) + { + await Task.Delay(PollInterval, cancellationToken); + drawnColor = ReadDrawnColor(); + } + + return drawnColor; + } + + /// + public void Dispose() + { + _window.Close(); + } + + /// + /// Reads the center pixel of the frame Windows composes for the window. + /// + /// The color of the center pixel. + private Color ReadDrawnColor() + { + TestPInvoke.GetClientRect(_handle, out var bounds); + using var bitmap = new Bitmap(bounds.right, bounds.bottom); + using (var graphics = Graphics.FromImage(bitmap)) + { + var context = graphics.GetHdc(); + try + { + TestPInvoke.PrintWindow(_handle, new HDC(context), RenderFullContent); + } + finally + { + graphics.ReleaseHdc(context); + } + } + + var pixel = bitmap.GetPixel(bounds.right / 2, bounds.bottom / 2); + return Color.FromRgb(pixel.R, pixel.G, pixel.B); + } +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json new file mode 100644 index 0000000..1c96c39 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "className": "TestPInvoke" +} diff --git a/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt new file mode 100644 index 0000000..65bb2d2 --- /dev/null +++ b/tests/Nice3point.Revit.Toolkit.Tests/NativeMethods.txt @@ -0,0 +1,2 @@ +GetClientRect +PrintWindow diff --git a/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj b/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj index c51569e..59baf50 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj +++ b/tests/Nice3point.Revit.Toolkit.Tests/Nice3point.Revit.Toolkit.Tests.csproj @@ -4,6 +4,7 @@ Exe true false + true Debug.R21;Debug.R22;Debug.R23;Debug.R24;Debug.R25;Debug.R26;Debug.R27 $(Configurations);Release.R21;Release.R22;Release.R23;Release.R24;Release.R25;Release.R26;Release.R27 @@ -14,6 +15,7 @@ + diff --git a/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs b/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs index 1d7c3fd..4369040 100644 --- a/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs +++ b/tests/Nice3point.Revit.Toolkit.Tests/RevitContextTests.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.IO; +using System.Windows.Media; using System.Windows.Threading; using Autodesk.Revit.UI; using Nice3point.TUnit.Revit; @@ -209,4 +211,51 @@ await Assert.That(() => scope.Dispose(); }).ThrowsNothing(); } + + [Test] + public async Task BeginModalLoopCompatibilityScope_TemplateOfThePreviousVersion_WindowDrawsTheNextFrame(CancellationToken cancellationToken) + { + // Arrange + var previousVersion = int.Parse(UiApplication.Application.VersionNumber, CultureInfo.InvariantCulture) - 1; + var templatePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Autodesk", $"RVT {previousVersion}", "Family Templates", "English", "Metric Detail Item.rft"); + if (!File.Exists(templatePath)) + { + Skip.Test($"Revit {previousVersion} with the English content is not installed"); + return; + } + + using var window = await ColorWindow.ShowAsync(Colors.Red, cancellationToken); + + // Act + using (RevitContext.BeginModalLoopCompatibilityScope()) + { + UiApplication.Application.NewFamilyDocument(templatePath).Close(false); + } + + window.Fill(Colors.Blue); + + // Assert + await Assert.That(await window.WaitForDrawnColorAsync(Colors.Blue, cancellationToken)).IsEqualTo(Colors.Blue); + } + + [Test] + public async Task BeginModalLoopCompatibilityScope_DisposedTwice_DoesNotThrow() + { + // Arrange + var scope = RevitContext.BeginModalLoopCompatibilityScope(); + + // Act & Assert + await Assert.That(() => + { + scope.Dispose(); + scope.Dispose(); + }).ThrowsNothing(); + } + + [Test] + public async Task BeginModalLoopCompatibilityScope_DisposedOnThreadWithoutWpf_DoesNotThrow() + { + // Act & Assert + await Assert.That(() => Task.Run(() => RevitContext.BeginModalLoopCompatibilityScope().Dispose())).ThrowsNothing(); + } }