diff --git a/.gitignore b/.gitignore index 6cf61f9b4..16163c052 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ build/tests/bin/* build/tests/obj/* ci-stamped/ ci-bin-manifest.json + +# local-only build overrides (never shipped) +build/appsettings.Development.json diff --git a/build/Build.csproj b/build/Build.csproj index 50b367931..a7f24e159 100644 --- a/build/Build.csproj +++ b/build/Build.csproj @@ -29,6 +29,9 @@ PreserveNewest + + PreserveNewest + diff --git a/build/Helpers/PyRevitPaths.cs b/build/Helpers/PyRevitPaths.cs index b1a06cc0a..a39e1e158 100644 --- a/build/Helpers/PyRevitPaths.cs +++ b/build/Helpers/PyRevitPaths.cs @@ -33,6 +33,7 @@ public static class PyRevitPaths public static string Engines2712NetCorePath => Path.Combine(BinPath, "netcore", "engines", "IPY2712PR"); public static string RuntimeSolution => Path.Combine(DevPath, "pyRevitLabs.PyRevit.Runtime", "pyRevitLabs.PyRevit.Runtime.sln"); + public static string ShellProject => Path.Combine(DevPath, "pyRevitLabs.PyRevit.Shell", "pyRevitLabs.PyRevit.Shell.csproj"); public static string DirectoryBuildProps => Path.Combine(DevPath, "Directory.Build.props"); public static string TelemetryServerPath => Path.Combine(DevPath, "pyRevitTelemetryServer"); diff --git a/build/Modules/BuildShellModule.cs b/build/Modules/BuildShellModule.cs new file mode 100644 index 000000000..c3a9d1246 --- /dev/null +++ b/build/Modules/BuildShellModule.cs @@ -0,0 +1,33 @@ +using Build.Helpers; +using Build.Options; +using Microsoft.Extensions.Options; +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Modules; + +namespace Build.Modules; + +// The interactive shell ships into the active engine folder (next to the loader/DLR it runs +// against), so it must build after the loaders/runtime have deployed the IronPython/DLR there. +// Building the per-fork configurations also runs the csproj DeployShell target, which copies +// pyRevitLabs.PyRevit.Shell.dll + ICSharpCode.AvalonEdit.dll into each engine folder. +[DependsOn] +public sealed class BuildShellModule(IOptions buildOptions) : Module +{ + protected override async Task ExecuteModuleAsync(IModuleContext context, CancellationToken cancellationToken) + { + var configuration = buildOptions.Value.Configuration; + + await DotNetBuildHelper.BuildProjectAsync( + context, + PyRevitPaths.ShellProject, + configuration + " IPY2712PR", + cancellationToken); + + await DotNetBuildHelper.BuildProjectAsync( + context, + PyRevitPaths.ShellProject, + configuration + " IPY342", + cancellationToken); + } +} \ No newline at end of file diff --git a/build/Program.cs b/build/Program.cs index c3e237c45..5d1eadef8 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -53,6 +53,7 @@ builder.Services.AddModule(); builder.Services.AddModule(); builder.Services.AddModule(); + builder.Services.AddModule(); builder.Services.AddModule(); builder.Services.AddModule(); builder.Services.AddModule(); diff --git a/dev/_build.py b/dev/_build.py index 164c3db9e..2abfc9f4e 100644 --- a/dev/_build.py +++ b/dev/_build.py @@ -37,4 +37,5 @@ def build_binaries(_: Dict[str, str]): labs.build_labs(_) labs.build_engines(_) labs.build_runtime(_) + labs.build_shell(_) autoc.build_autocmp(_) diff --git a/dev/_labs.py b/dev/_labs.py index 0ade94886..3aa3f6437 100644 --- a/dev/_labs.py +++ b/dev/_labs.py @@ -160,3 +160,14 @@ def build_runtime(args: Dict[str, str]): _build(f"runtime {IPY2712PR}", configs.RUNTIME, config=config + f" {IPY2712PR}") _build(f"runtime {IPY342}", configs.RUNTIME, config=config + f" {IPY342}") + + +def build_shell(args: Dict[str, str]): + """Build pyRevit interactive shell (per IronPython fork, all target frameworks).""" + config = args.get("") or "Release" + + IPY2712PR = "IPY2712PR" + IPY342 = "IPY342" + + _build(f"shell {IPY2712PR}", configs.SHELL, config=config + f" {IPY2712PR}") + _build(f"shell {IPY342}", configs.SHELL, config=config + f" {IPY342}") diff --git a/dev/pyRevitLabs.PyRevit.Runtime/InteractiveEngine.cs b/dev/pyRevitLabs.PyRevit.Runtime/InteractiveEngine.cs new file mode 100644 index 000000000..5767608db --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Runtime/InteractiveEngine.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +using Autodesk.Revit.UI; + +namespace PyRevitLabs.PyRevit.Runtime { + /// + /// Thin entry used by the interactive shell to configure its IronPython engine exactly like a + /// pyRevit script run: full builtins (incl. __scriptruntime__, so + /// from pyrevit import ... works), RevitAPI loaded, the active fork's bundled stdlib, and + /// the caller's sys.path. Built into the per-fork runtime, so it operates on the same DLR + /// identity as the engine pyRevit is configured to use. The shell's console owns stdin/stdout, + /// so engine streams are intentionally left untouched here. + /// + public static class InteractiveEngine { + /// + /// Apply the standard pyRevit environment to an interactive shell's IronPython engine + /// (the one its DLR console already created for the active fork). + /// + public static void ConfigureIronPythonEngine(Microsoft.Scripting.Hosting.ScriptEngine engine, UIApplication uiapp, IList searchPaths) { + var scriptData = new ScriptData { + ScriptPath = "interactive_shell.py", + ConfigScriptPath = "interactive_shell.py", + CommandName = "Interactive Shell", + CommandBundle = "pyRevit Shell", + CommandExtension = "pyRevitCore", + CommandUniqueId = "pyrevit-interactive-shell", + CommandControlId = "pyrevit-interactive-shell", + }; + + var configs = new ScriptRuntimeConfigs { + UIApp = uiapp, + SearchPaths = searchPaths != null ? new List(searchPaths) : new List(), + Arguments = new List(), + Variables = new Dictionary(), + EngineConfigs = "{\"clean\":false,\"full_frame\":false,\"persistent\":true}", + }; + + var runtime = new ScriptRuntime(scriptData, configs); + + // mirror IronPythonEngine.Start's assembly access + engine.Runtime.LoadAssembly(typeof(PyRevitLoader.ScriptExecutor).Assembly); + engine.Runtime.LoadAssembly(typeof(ScriptExecutor).Assembly); + engine.Runtime.LoadAssembly(typeof(Autodesk.Revit.DB.Document).Assembly); + engine.Runtime.LoadAssembly(typeof(Autodesk.Revit.UI.TaskDialog).Assembly); + + // pyRevit's bundled standard library for the active fork + new PyRevitLoader.ScriptExecutor().AddEmbeddedLib(engine); + + // sys.path captured from the launching pyRevit engine (pyrevitlib, site-packages, ...) + engine.SetSearchPaths(configs.SearchPaths); + + // full pyRevit builtins so the REPL matches a normal script's environment + IronPythonEngine.InjectBuiltins(engine, runtime, recoveredFromCache: false, typeId: "pyrevit-interactive-shell"); + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Runtime/IronPythonEngine.cs b/dev/pyRevitLabs.PyRevit.Runtime/IronPythonEngine.cs index 06dc4a8af..859fd1cda 100644 --- a/dev/pyRevitLabs.PyRevit.Runtime/IronPythonEngine.cs +++ b/dev/pyRevitLabs.PyRevit.Runtime/IronPythonEngine.cs @@ -209,19 +209,26 @@ private void SetupStreams(ref ScriptRuntime runtime) { } private void SetupBuiltins(ref ScriptRuntime runtime) { + InjectBuiltins(Engine, runtime, RecoveredFromCache, TypeId); + } + + // Inject the standard pyRevit builtins onto an engine's builtin module. Shared with the + // interactive shell so a REPL gets the same environment (incl. __scriptruntime__) as a + // normal script run. + internal static void InjectBuiltins(Microsoft.Scripting.Hosting.ScriptEngine engine, ScriptRuntime runtime, bool recoveredFromCache, string typeId) { // BUILTINS ----------------------------------------------------------------------------------------------- // Get builtin to add custom variables - var builtin = IronPython.Hosting.Python.GetBuiltinModule(Engine); + var builtin = IronPython.Hosting.Python.GetBuiltinModule(engine); // Add timestamp and executuin uuid builtin.SetVariable("__execid__", runtime.ExecId); builtin.SetVariable("__timestamp__", runtime.ExecTimestamp); // Let commands know if they're being run in a cached engine - builtin.SetVariable("__cachedengine__", RecoveredFromCache); + builtin.SetVariable("__cachedengine__", recoveredFromCache); // Add current engine id to builtins - builtin.SetVariable("__cachedengineid__", TypeId); + builtin.SetVariable("__cachedengineid__", typeId); // Add this script executor to the the builtin to be globally visible everywhere // This support pyrevit functionality to ask information about the current executing command diff --git a/dev/pyRevitLabs.PyRevit.Shell.DevHost/Program.cs b/dev/pyRevitLabs.PyRevit.Shell.DevHost/Program.cs new file mode 100644 index 000000000..25f67fa45 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell.DevHost/Program.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Threading; +using Microsoft.Scripting.Hosting; +using PyRevitLabs.PyRevit.Shell; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell.DevHost; + +/// +/// Development-only launcher that hosts the interactive Python Shell window outside of Revit, so +/// the REPL/editor can be iterated on without starting Revit. It is not built by the product +/// pipeline and is never shipped to end users. +/// +/// It mirrors ShellLauncher's modal path but drops the Revit-specific pieces +/// (UIApplication, ExternalEvent dispatch, runtime-builtin injection): statements run on the UI +/// dispatcher and the engine gets a plain IronPython environment with pyRevit's library paths on +/// sys.path. +/// +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + var root = FindRepositoryRoot(); + var engineDir = Path.Combine(root, "bin", "netcore", "engines", "IPY2712PR"); + + if (!File.Exists(Path.Combine(engineDir, "pyRevitLabs.PyRevit.Shell.dll"))) + { + Console.Error.WriteLine( + "The shell is not built yet. Build it first, e.g.:" + Environment.NewLine + + " cd build && dotnet run -c Release -- ci" + Environment.NewLine + + "or just the shell:" + Environment.NewLine + + " dotnet build dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj -c \"Release IPY2712PR\""); + return 1; + } + + var searchPaths = BuildSearchPaths(root, engineDir); + var useDark = args.Contains("--dark", StringComparer.OrdinalIgnoreCase); + var consoleOnly = args.Contains("--console", StringComparer.OrdinalIgnoreCase); + + Window window; + IronPythonConsoleControl consoleControl; + + if (consoleOnly) + { + var shell = new InteractiveShellWindow(); + shell.ApplyTheme(useDark); + window = shell; + consoleControl = shell.ConsoleControl; + } + else + { + var editor = new InteractiveEditorWindow(); + editor.ApplyTheme(useDark); + window = editor; + consoleControl = editor.ConsoleControl; + } + + var mainDispatcher = Dispatcher.CurrentDispatcher; + + consoleControl.WithConsoleHost(host => + { + // Statements run on the UI dispatcher, matching the in-Revit modal dispatch so this + // host behaves like the real shell rather than the console's native background thread. + host.Console.SetCommandDispatcher(command => RunOnDispatcher(mainDispatcher, command)); + host.Editor.SetCompletionDispatcher(command => RunOnDispatcher(mainDispatcher, command)); + + ConfigureStandaloneEngine(host.Engine, host.Console.ScriptScope, searchPaths); + host.Console.ScriptScope.SetVariable("__window__", window); + }); + + window.ShowDialog(); + return 0; + } + + private static IList BuildSearchPaths(string root, string engineDir) + { + var paths = new List { engineDir }; + + var stdlibZip = Path.Combine(root, "dev", "libs", "IronPython", "python_2712pr_lib.zip"); + if (File.Exists(stdlibZip)) + paths.Add(stdlibZip); + + AddIfExists(paths, Path.Combine(root, "pyrevitlib")); + AddIfExists(paths, Path.Combine(root, "site-packages")); + AddIfExists(paths, Path.Combine(root, "extensions")); + return paths; + } + + private static void AddIfExists(List paths, string dir) + { + if (Directory.Exists(dir)) + paths.Add(dir); + } + + private static void ConfigureStandaloneEngine(ScriptEngine engine, ScriptScope scope, IList searchPaths) + { + engine.SetSearchPaths(searchPaths); + + // Safe defaults for the reserved pyrevit builtins the interactive shell expects, so user + // snippets that read them don't NameError in the no-Revit host. + try + { + engine.Execute(StubBuiltinsSnippet, scope); + } + catch + { + // Best effort: the REPL still works without these defaults. + } + } + + // Poll the dispatcher operation so a Ctrl+C keyboard interrupt can break a long run. + private static void RunOnDispatcher(Dispatcher dispatcher, Action command) + { + if (command == null) + return; + + var operation = dispatcher.BeginInvoke(DispatcherPriority.Normal, command); + while (operation.Status != DispatcherOperationStatus.Completed) + operation.Wait(TimeSpan.FromSeconds(1)); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "pyRevitfile"))) + return current.FullName; + current = current.Parent; + } + + return Directory.GetCurrentDirectory(); + } + + private const string StubBuiltinsSnippet = +"try: __execid__\nexcept NameError: __execid__ = ''\n" + +"try: __timestamp__\nexcept NameError: __timestamp__ = ''\n" + +"try: __cachedengine__\nexcept NameError: __cachedengine__ = False\n" + +"try: __cachedengineid__\nexcept NameError: __cachedengineid__ = None\n" + +"try: __scriptruntime__\nexcept NameError: __scriptruntime__ = None\n" + +"try: __revit__\nexcept NameError: __revit__ = None\n" + +"try: __commanddata__\nexcept NameError: __commanddata__ = None\n" + +"try: __elements__\nexcept NameError: __elements__ = None\n" + +"try: __uibutton__\nexcept NameError: __uibutton__ = None\n" + +"try: __commandpath__\nexcept NameError: __commandpath__ = ''\n" + +"try: __configcommandpath__\nexcept NameError: __configcommandpath__ = ''\n" + +"try: __commandname__\nexcept NameError: __commandname__ = 'Interactive Shell (dev)'\n" + +"try: __commandbundle__\nexcept NameError: __commandbundle__ = 'pyRevit Shell'\n" + +"try: __commandextension__\nexcept NameError: __commandextension__ = 'pyRevitCore'\n" + +"try: __commanduniqueid__\nexcept NameError: __commanduniqueid__ = 'pyrevit-interactive-shell'\n" + +"try: __commandcontrolid__\nexcept NameError: __commandcontrolid__ = 'pyrevit-interactive-shell'\n" + +"try: __forceddebugmode__\nexcept NameError: __forceddebugmode__ = False\n" + +"try: __shiftclick__\nexcept NameError: __shiftclick__ = False\n" + +"try: __result__\nexcept NameError: __result__ = {}\n" + +"try: __eventsender__\nexcept NameError: __eventsender__ = None\n" + +"try: __eventargs__\nexcept NameError: __eventargs__ = None\n"; +} + diff --git a/dev/pyRevitLabs.PyRevit.Shell.DevHost/README.md b/dev/pyRevitLabs.PyRevit.Shell.DevHost/README.md new file mode 100644 index 000000000..d56221b5a --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell.DevHost/README.md @@ -0,0 +1,92 @@ +# pyRevit Shell — Dev Host (NOT shipped) + +`pyRevitShellDevHost` is a **development-only** executable that launches the interactive Python +Shell (REPL + AvalonEdit editor) **outside of Revit**, so you can iterate on the shell UI/REPL +without starting Revit. + +- It is **not** referenced by `pyRevitLabs.sln`, the runtime solution, or the product build + pipeline (`build/` / `pyRevit.Build.exe`), so it is never built in CI and never ships to end + users. Its `bin/`/`obj/` are gitignored. +- It references the already-built shell + IronPython/AvalonEdit DLLs from the deployed engine + folder (`bin/netcore/engines/IPY2712PR/`), so the shell must be built first (once). +- It mirrors `ShellLauncher`'s modal path but drops the Revit-specific pieces (`UIApplication`, + `ExternalEvent`, runtime-builtin injection). Statements run on the UI dispatcher and the + engine gets a plain IronPython environment with pyRevit's library paths on `sys.path`. + +## Prerequisites + +The IronPython engine DLLs must be present in `bin/netcore/engines/IPY2712PR/` (they get there +after a product build). If `bin/` is empty, run the build once: + +```powershell +cd build +dotnet run -c Release -- ci +``` + +…or just build the shell (which also deploys it to the engine folder): + +```powershell +dotnet build dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj -c "Release IPY2712PR" +``` + +## Build & run + +From the repository root (the shell must be built first — see Prerequisites): + +```powershell +dotnet run --project dev/pyRevitLabs.PyRevit.Shell.DevHost -c Debug +``` + +This compiles the host against the already-deployed shell DLL and runs it. After you change +shell sources, rebuild the shell first: + +```powershell +dotnet build dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj -c "Debug IPY2712PR" +``` + +Or do it in one step by enabling the `BuildShellBeforeHost` target (it rebuilds the shell and +deploys the fresh DLL before compiling the host; needs a NuGet restore, so it requires network +or a warm package cache): + +```powershell +dotnet run --project dev/pyRevitLabs.PyRevit.Shell.DevHost -c Debug -p:BuildShellBeforeHost=true +``` + +To build a runnable `.exe` instead of `dotnet run`: + +```powershell +dotnet build dev/pyRevitLabs.PyRevit.Shell.DevHost -c Debug -p:BuildShellBeforeHost=false +# then run: +dev\pyRevitLabs.PyRevit.Shell.DevHost\bin\Debug\net8.0-windows\pyRevitShellDevHost.exe +``` + +## Options + +| Arg | Effect | +|-----|--------| +| *(default)* | Open the editor + REPL window (`InteractiveEditorWindow`) | +| `--console` | Open the REPL-only window (`InteractiveShellWindow`) | +| `--dark` | Render with the dark theme (light by default) | + +App arguments go after `--` so `dotnet run` does not consume them: + +```powershell +# editor + REPL, dark theme +dotnet run --project dev/pyRevitLabs.PyRevit.Shell.DevHost -c Debug -- --dark + +# REPL-only window, dark theme +dotnet run --project dev/pyRevitLabs.PyRevit.Shell.DevHost -c Debug -- --dark --console + +# from the built exe +dev\pyRevitLabs.PyRevit.Shell.DevHost\bin\Debug\net8.0-windows\pyRevitShellDevHost.exe --dark +``` + +## Notes + +- This host exercises the same `IronPythonConsoleControl` / `PythonConsoleHost` / + `InteractiveShellWindow` / `InteractiveEditorWindow` types the real shell uses, so it is a + faithful smoke test for the shell surface — just without a Revit API context. Anything that + needs `__revit__` / the pyRevit runtime will not work here (by design). +- Close Revit before rebuilding the shell (engine DLLs in `bin/netfx|netcore/engines/` are + locked while Revit runs — same constraint as the normal product build). + diff --git a/dev/pyRevitLabs.PyRevit.Shell.DevHost/pyRevitShellDevHost.csproj b/dev/pyRevitLabs.PyRevit.Shell.DevHost/pyRevitShellDevHost.csproj new file mode 100644 index 000000000..3545741b3 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell.DevHost/pyRevitShellDevHost.csproj @@ -0,0 +1,58 @@ + + + + WinExe + net8.0-windows + true + false + x64 + pyRevitShellDevHost + PyRevitLabs.PyRevit.Shell.DevHost + latest + + + + + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.PyRevit.Shell.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\ICSharpCode.AvalonEdit.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.IronPython.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.IronPython.Modules.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.Microsoft.Dynamic.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.Microsoft.Scripting.dll + true + + + $(PyRevitEnginesDir)\IPY2712PR\pyRevitLabs.Microsoft.Scripting.Metadata.dll + true + + + + + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/CommandLineHistory.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/CommandLineHistory.cs new file mode 100644 index 000000000..5411d63e5 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/CommandLineHistory.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Collections.Generic; + +namespace PythonConsoleControl +{ + /// + /// Stores the command line history for the PythonConsole. + /// + public class CommandLineHistory + { + List lines = new List(); + int position; + + public CommandLineHistory() + { + } + + /// + /// Adds the command line to the history. + /// + public void Add(string line) + { + if (!String.IsNullOrEmpty(line)) + { + int index = lines.Count - 1; + if (index >= 0) + { + if (lines[index] != line) + { + lines.Add(line); + } + } + else + { + lines.Add(line); + } + } + position = lines.Count; + } + + /// + /// Gets the current command line. By default this will be the last command line entered. + /// + public string Current + { + get + { + if ((position >= 0) && (position < lines.Count)) + { + return lines[position]; + } + return null; + } + } + + /// + /// Moves to the next command line. + /// + /// False if the current position is at the end of the command line history. + public bool MoveNext() + { + int nextPosition = position + 1; + if (nextPosition < lines.Count) + { + ++position; + } + return nextPosition < lines.Count; + } + + /// + /// Moves to the previous command line. + /// + /// False if the current position is at the start of the command line history. + public bool MovePrevious() + { + if (position >= 0) + { + if (position == 0) + { + return false; + } + --position; + } + return position >= 0; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonCompletionData.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonCompletionData.cs new file mode 100644 index 000000000..c238d94d7 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonCompletionData.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2010 Joe Moorhouse + +using ICSharpCode.AvalonEdit.CodeCompletion; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Editing; +using Microsoft.Scripting.Hosting.Shell; +using System; + +namespace PythonConsoleControl +{ + /// + /// Implements AvalonEdit ICompletionData interface to provide the entries in the completion drop down. + /// + public class PythonCompletionData : ICompletionData + { + private CommandLine commandLine; + + public PythonCompletionData(string text, string stub, CommandLine commandLine, bool isInstance) + { + this.Text = text; + this.Stub = stub; + this.commandLine = commandLine; + this.IsInstance = isInstance; + } + + public System.Windows.Media.ImageSource Image + { + get { return null; } + } + + public string Text { get; private set; } + + public string Stub { get; private set; } + + public bool IsInstance { get; private set; } + + // Use this property if you want to show a fancy UIElement in the drop down list. + public object Content + { + get { return this.Text; } + } + + public object Description + { + get + { + // Do nothing: description now updated externally and asynchronously. + return "Not available"; + } + } + + public double Priority { get { return 0; } } + + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + textArea.Document.Replace(completionSegment, this.Text); + } + } +} \ No newline at end of file diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsole.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsole.cs new file mode 100644 index 000000000..9837eb078 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsole.cs @@ -0,0 +1,782 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Microsoft.Scripting.Hosting.Shell; +using System.Windows.Input; +using Microsoft.Scripting; +using Microsoft.Scripting.Hosting; +using System.Diagnostics; +using System.Windows; +using System.Windows.Threading; +using ICSharpCode.AvalonEdit.Editing; +using ICSharpCode.AvalonEdit.Document; +using Style = Microsoft.Scripting.Hosting.Shell.Style; + +namespace PythonConsoleControl +{ + public delegate void ConsoleInitializedEventHandler(object sender, EventArgs e); + + /// + /// Custom IronPython console. The command dispacher runs on a separate UI thread from the REPL + /// and also from the WPF control. + /// + public class PythonConsole : IConsole, IDisposable + { + + Action commandDispatcher; + public Action GetCommandDispatcherSafe() + { + if (commandDispatcher == null) + { + try + { + var languageContext = Microsoft.Scripting.Hosting.Providers.HostingHelpers.GetLanguageContext(commandLine.ScriptScope.Engine); + var pythonContext = (IronPython.Runtime.PythonContext)languageContext; + commandDispatcher = pythonContext.GetSetCommandDispatcher(null); + pythonContext.GetSetCommandDispatcher(commandDispatcher); + } + catch (Exception ex) + { + Debug.WriteLine("Failed to get dispatcher: " + ex.Message); + commandDispatcher = action => action(); // fallback dispatcher + } + } + return commandDispatcher; + } + + bool allowFullAutocompletion = true; + public bool AllowFullAutocompletion + { + get { return allowFullAutocompletion; } + set { allowFullAutocompletion = value; } + } + + bool disableAutocompletionForCallables = true; + public bool DisableAutocompletionForCallables + { + get { return disableAutocompletionForCallables; } + set + { + if (textEditor.CompletionProvider != null) textEditor.CompletionProvider.ExcludeCallables = value; + disableAutocompletionForCallables = value; + } + } + + bool allowCtrlSpaceAutocompletion = true; + public bool AllowCtrlSpaceAutocompletion + { + get { return allowCtrlSpaceAutocompletion; } + set { allowCtrlSpaceAutocompletion = value; } + } + + PythonTextEditor textEditor; + int lineReceivedEventIndex = 0; // The index into the waitHandles array where the lineReceivedEvent is stored. + ManualResetEvent lineReceivedEvent = new ManualResetEvent(false); + ManualResetEvent disposedEvent = new ManualResetEvent(false); + AutoResetEvent statementsExecutionRequestedEvent = new AutoResetEvent(false); + WaitHandle[] waitHandles; + int promptLength = 4; + List previousLines = new List(); + CommandLine commandLine; + CommandLineHistory commandLineHistory = new CommandLineHistory(); + + volatile bool executing = false; + + // This is the thread upon which all commands execute unless the dipatcher is overridden. + Thread dispatcherThread; + public Dispatcher dispatcher; + + string scriptText = String.Empty; + bool consoleInitialized = false; + string prompt; + + public event ConsoleInitializedEventHandler ConsoleInitialized; + + public ScriptScope ScriptScope + { + get { return commandLine.ScriptScope; } + } + + public PythonConsole(PythonTextEditor textEditor, CommandLine commandLine) + { + waitHandles = new WaitHandle[] { lineReceivedEvent, disposedEvent }; + + this.commandLine = commandLine; + this.textEditor = textEditor; + textEditor.CompletionProvider = new PythonConsoleCompletionDataProvider(commandLine) { ExcludeCallables = disableAutocompletionForCallables }; + textEditor.PreviewKeyDown += textEditor_PreviewKeyDown; + textEditor.TextEntering += textEditor_TextEntering; + dispatcherThread = new Thread(new ThreadStart(DispatcherThreadStartingPoint)); + dispatcherThread.SetApartmentState(ApartmentState.STA); + dispatcherThread.IsBackground = true; + dispatcherThread.Start(); + + // Only required when running outside REP loop. + prompt = ">>> "; + + // Set commands: + this.textEditor.textArea.Dispatcher.Invoke(new Action(delegate() + { + CommandBinding pasteBinding = null; + CommandBinding copyBinding = null; + CommandBinding cutBinding = null; + CommandBinding undoBinding = null; + CommandBinding deleteBinding = null; + foreach (CommandBinding commandBinding in (this.textEditor.textArea.CommandBindings)) + { + if (commandBinding.Command == ApplicationCommands.Paste) pasteBinding = commandBinding; + if (commandBinding.Command == ApplicationCommands.Copy) copyBinding = commandBinding; + if (commandBinding.Command == ApplicationCommands.Cut) cutBinding = commandBinding; + if (commandBinding.Command == ApplicationCommands.Undo) undoBinding = commandBinding; + if (commandBinding.Command == ApplicationCommands.Delete) deleteBinding = commandBinding; + } + // Remove current bindings completely from control. These are static so modifying them will cause other + // controls' behaviour to change too. + if (pasteBinding != null) this.textEditor.textArea.CommandBindings.Remove(pasteBinding); + if (copyBinding != null) this.textEditor.textArea.CommandBindings.Remove(copyBinding); + if (cutBinding != null) this.textEditor.textArea.CommandBindings.Remove(cutBinding); + if (undoBinding != null) this.textEditor.textArea.CommandBindings.Remove(undoBinding); + if (deleteBinding != null) this.textEditor.textArea.CommandBindings.Remove(deleteBinding); + this.textEditor.textArea.CommandBindings.Add(new CommandBinding(ApplicationCommands.Paste, OnPaste, CanPaste)); + this.textEditor.textArea.CommandBindings.Add(new CommandBinding(ApplicationCommands.Copy, OnCopy, PythonEditingCommandHandler.CanCutOrCopy)); + this.textEditor.textArea.CommandBindings.Add(new CommandBinding(ApplicationCommands.Cut, PythonEditingCommandHandler.OnCut, CanCut)); + this.textEditor.textArea.CommandBindings.Add(new CommandBinding(ApplicationCommands.Undo, OnUndo, CanUndo)); + this.textEditor.textArea.CommandBindings.Add(new CommandBinding(ApplicationCommands.Delete, PythonEditingCommandHandler.OnDelete(ApplicationCommands.NotACommand), CanDeleteCommand)); + + })); + // Set dispatcher to run on a UI thread independent of both the Control UI thread and thread running the REPL. + WhenConsoleInitialized(delegate + { + SetCommandDispatcher(DispatchCommand); + }); + } + + public Action GetCommandDispatcher() + { + var languageContext = Microsoft.Scripting.Hosting.Providers.HostingHelpers.GetLanguageContext(commandLine.ScriptScope.Engine); + var pythonContext = (IronPython.Runtime.PythonContext)languageContext; + var result = pythonContext.GetSetCommandDispatcher(null); + pythonContext.GetSetCommandDispatcher(result); + return result; + } + + public void SetCommandDispatcher(Action newDispatcher) + { + var languageContext = Microsoft.Scripting.Hosting.Providers.HostingHelpers.GetLanguageContext(commandLine.ScriptScope.Engine); + var pythonContext = (IronPython.Runtime.PythonContext)languageContext; + pythonContext.GetSetCommandDispatcher(newDispatcher); + } + + protected void DispatchCommand(Delegate command) + { + if (command != null) + { + // Slightly involved form to enable keyboard interrupt to work. + executing = true; + var operation = dispatcher.BeginInvoke(DispatcherPriority.Normal, command); + while (executing) + { + if (operation.Status != DispatcherOperationStatus.Completed) + operation.Wait(TimeSpan.FromSeconds(1)); + if (operation.Status == DispatcherOperationStatus.Completed) + executing = false; + } + } + } + + /// + /// Perform action only after the console was initialized. + /// + public void WhenConsoleInitialized(Action action) + { + if (consoleInitialized) + { + action(); + } + else + { + ConsoleInitialized += (sender, args) => action(); + } + } + + private void DispatcherThreadStartingPoint() + { + dispatcher = new Window().Dispatcher; + while (true) + { + try + { + System.Windows.Threading.Dispatcher.Run(); + } + catch (ThreadAbortException tae) + { + if (tae.ExceptionState is Microsoft.Scripting.KeyboardInterruptException) + { + Thread.ResetAbort(); + executing = false; + } + } + } + } + + public void SetDispatcher(Dispatcher dispatcher) + { + this.dispatcher = dispatcher; + } + + public void Dispose() + { + disposedEvent.Set(); + textEditor.PreviewKeyDown -= textEditor_PreviewKeyDown; + textEditor.TextEntering -= textEditor_TextEntering; + } + + public TextWriter Output + { + get { return null; } + set { } + } + + public TextWriter ErrorOutput + { + get { return null; } + set { } + } + + #region CommandHandling + protected void CanPaste(object target, CanExecuteRoutedEventArgs args) + { + if (IsInReadOnlyRegion) + { + args.CanExecute = false; + } + else + args.CanExecute = true; + } + + protected void CanCut(object target, CanExecuteRoutedEventArgs args) + { + if (!CanDelete) + { + args.CanExecute = false; + } + else + PythonEditingCommandHandler.CanCutOrCopy(target, args); + } + + protected void CanDeleteCommand(object target, CanExecuteRoutedEventArgs args) + { + if (!CanDelete) + { + args.CanExecute = false; + } + else + PythonEditingCommandHandler.CanDelete(target, args); + } + + protected void CanUndo(object target, CanExecuteRoutedEventArgs args) + { + args.CanExecute = false; + } + + protected void OnPaste(object target, ExecutedRoutedEventArgs args) + { + if (target != textEditor.textArea) return; + TextArea textArea = textEditor.textArea; + if (textArea != null && textArea.Document != null) + { + Debug.WriteLine(Clipboard.GetText(TextDataFormat.Html)); + + // convert text back to correct newlines for this document + string newLine = TextUtilities.GetNewLineFromDocument(textArea.Document, textArea.Caret.Line); + string text = TextUtilities.NormalizeNewLines(Clipboard.GetText(), newLine); + string[] commands = text.Split(new String[] { newLine }, StringSplitOptions.None); + string scriptText = ""; + if (commands.Length > 1) + { + text = newLine; + foreach (string command in commands) + { + text += "... " + command + newLine; + scriptText += command.Replace("\t", " ") + newLine; + } + } + + if (!string.IsNullOrEmpty(text)) + { + bool fullLine = textArea.Options.CutCopyWholeLine && Clipboard.ContainsData(LineSelectedType); + bool rectangular = Clipboard.ContainsData(RectangleSelection.RectangularSelectionDataType); + if (fullLine) + { + DocumentLine currentLine = textArea.Document.GetLineByNumber(textArea.Caret.Line); + if (textArea.ReadOnlySectionProvider.CanInsert(currentLine.Offset)) + { + textArea.Document.Insert(currentLine.Offset, text); + } + } + else if (rectangular && textArea.Selection.IsEmpty) + { + if (!RectangleSelection.PerformRectangularPaste(textArea, textArea.Caret.Position, text, false)) + textEditor.Write(text, false, false); + } + else + { + textEditor.Write(text, false, false); + } + } + textArea.Caret.BringCaretToView(); + args.Handled = true; + + if (commands.Length > 1) + { + lock (this.scriptText) + { + this.scriptText = scriptText; + } + dispatcher.BeginInvoke(new Action(delegate() { ExecuteStatements(); })); + } + } + } + + protected void OnCopy(object target, ExecutedRoutedEventArgs args) + { + if (target != textEditor.textArea) return; + if (textEditor.SelectionLength == 0 && executing) + { + // Send the 'Ctrl-C' abort + //if (!IsInReadOnlyRegion) + //{ + MoveToHomePosition(); + //textEditor.Column = GetLastTextEditorLine().Length + 1; + //textEditor.Write(Environment.NewLine); + //} + dispatcherThread.Abort(new Microsoft.Scripting.KeyboardInterruptException("")); + args.Handled = true; + } + else PythonEditingCommandHandler.OnCopy(target, args); + } + + const string LineSelectedType = "MSDEVLineSelect"; // This is the type VS 2003 and 2005 use for flagging a whole line copy + + protected void OnUndo(object target, ExecutedRoutedEventArgs args) + { + } + #endregion + + /// + /// Run externally provided statements in the Console Engine. + /// + /// + public void RunStatements(string statements) + { + MoveToHomePosition(); + lock (this.scriptText) + { + this.scriptText = statements; + } + dispatcher.BeginInvoke(new Action(delegate() { ExecuteStatements(); })); + } + + /// + /// Run on the statement execution thread. + /// + void ExecuteStatements() + { + lock (scriptText) + { + textEditor.Write("\r\n"); + ScriptSource scriptSource = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(scriptText, SourceCodeKind.Statements); + string error = ""; + try + { + executing = true; + var errors = new ErrorReporter(); + var command = scriptSource.Compile(errors); + if (command == null) + { + // compilation failed + error = "Syntax Error: " + string.Join("\nSyntax Error: ", errors.Errors) + "\n"; + } + else + { + Exception capturedEx = null; + var dispatcher = GetCommandDispatcherSafe(); + + ManualResetEventSlim done = new ManualResetEventSlim(); + + dispatcher(() => + { + try + { + scriptSource.Execute(commandLine.ScriptScope); + } + catch (Exception ex) + { + capturedEx = ex; + } + finally + { + done.Set(); + } + }); + + done.Wait(); + + if (capturedEx != null) + { + var eo = commandLine.ScriptScope.Engine.GetService(); + error = eo.FormatException(capturedEx) + Environment.NewLine; + } + } + } + catch (ThreadAbortException tae) + { + if (tae.ExceptionState is Microsoft.Scripting.KeyboardInterruptException) Thread.ResetAbort(); + error = "KeyboardInterrupt" + System.Environment.NewLine; + } + catch (Microsoft.Scripting.SyntaxErrorException exception) + { + ExceptionOperations eo; + eo = commandLine.ScriptScope.Engine.GetService(); + error = eo.FormatException(exception); + } + catch (Exception exception) + { + ExceptionOperations eo; + eo = commandLine.ScriptScope.Engine.GetService(); + error = eo.FormatException(exception) + System.Environment.NewLine; + } + executing = false; + if (error != "") textEditor.Write(error); + textEditor.Write(prompt); + } + } + + /// + /// Returns the next line typed in by the console user. If no line is available this method + /// will block. + /// + public string ReadLine(int autoIndentSize) + { + string indent = String.Empty; + if (autoIndentSize > 0) + { + indent = String.Empty.PadLeft(autoIndentSize); + Write(indent, Style.Prompt); + } + + string line = ReadLineFromTextEditor(); + if (line != null) + { + return indent + line; + } + return null; + } + + /// + /// Writes text to the console. + /// + public void Write(string text, Style style) + { + textEditor.Write(text); + if (style == Style.Prompt) + { + promptLength = text.Length; + if (!consoleInitialized) + { + consoleInitialized = true; + if (ConsoleInitialized != null) ConsoleInitialized(this, EventArgs.Empty); + } + } + } + + /// + /// Writes text followed by a newline to the console. + /// + public void WriteLine(string text, Style style) + { + Write(text + Environment.NewLine, style); + } + + /// + /// Writes an empty line to the console. + /// + public void WriteLine() + { + Write(Environment.NewLine, Style.Out); + } + + /// + /// Indicates whether there is a line already read by the console and waiting to be processed. + /// + public bool IsLineAvailable + { + get + { + lock (previousLines) + { + return previousLines.Count > 0; + } + } + } + + /// + /// Gets the text that is yet to be processed from the console. This is the text that is being + /// typed in by the user who has not yet pressed the enter key. + /// + public string GetCurrentLine() + { + string fullLine = GetLastTextEditorLine(); + return fullLine.Substring(promptLength); + } + + /// + /// Gets the lines that have not been returned by the ReadLine method. This does not + /// include the current line. + /// + public string[] GetUnreadLines() + { + return previousLines.ToArray(); + } + + string GetLastTextEditorLine() + { + return textEditor.GetLine(textEditor.TotalLines - 1); + } + + string ReadLineFromTextEditor() + { + int result = WaitHandle.WaitAny(waitHandles); + if (result == lineReceivedEventIndex) + { + lock (previousLines) + { + string line = previousLines[0]; + previousLines.RemoveAt(0); + if (previousLines.Count == 0) + { + lineReceivedEvent.Reset(); + } + return line; + } + } + return null; + } + + /// + /// Processes characters entered into the text editor by the user. + /// + void textEditor_PreviewKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Delete: + if (!CanDelete) e.Handled = true; + return; + case Key.Tab: + if (IsInReadOnlyRegion) e.Handled = true; + return; + case Key.Back: + if (!CanBackspace) e.Handled = true; + return; + case Key.Home: + MoveToHomePosition(); + e.Handled = true; + return; + case Key.Down: + if (!IsInReadOnlyRegion) MoveToNextCommandLine(); + e.Handled = true; + return; + case Key.Up: + if (!IsInReadOnlyRegion) MoveToPreviousCommandLine(); + e.Handled = true; + return; + } + } + + /// + /// Processes characters entering into the text editor by the user. + /// + void textEditor_TextEntering(object sender, TextCompositionEventArgs e) + { + if (e.Text.Length > 0) + { + if (!char.IsLetterOrDigit(e.Text[0]) || e.Text[0] == '_') // Underscore is a fairly common character in Revit API names. + { + // Whenever a non-letter is typed while the completion window is open, + // insert the currently selected element. + textEditor.RequestCompletioninsertion(e); + } + } + + if (IsInReadOnlyRegion) + { + e.Handled = true; + } + else + { + if (e.Text[0] == '\n') + { + OnEnterKeyPressed(); + } + + if (e.Text[0] == '.' && allowFullAutocompletion) + { + textEditor.ShowCompletionWindow(); + } + + if ((e.Text[0] == ' ') && (Keyboard.Modifiers == ModifierKeys.Control)) + { + e.Handled = true; + if (allowCtrlSpaceAutocompletion) textEditor.ShowCompletionWindow(); + } + } + } + + /// + /// Move cursor to the end of the line before retrieving the line. + /// + void OnEnterKeyPressed() + { + textEditor.StopCompletion(); + if (textEditor.WriteInProgress) return; + lock (previousLines) + { + // Move cursor to the end of the line. + textEditor.Column = GetLastTextEditorLine().Length + 1; + + // Append line. + string currentLine = GetCurrentLine(); + previousLines.Add(currentLine); + commandLineHistory.Add(currentLine); + + lineReceivedEvent.Set(); + } + } + + /// + /// Returns true if the cursor is in a readonly text editor region. + /// + bool IsInReadOnlyRegion + { + get { return IsCurrentLineReadOnly || IsInPrompt; } + } + + /// + /// Only the last line in the text editor is not read only. + /// + bool IsCurrentLineReadOnly + { + get { return textEditor.Line < textEditor.TotalLines; } + } + + /// + /// Determines whether the current cursor position is in a prompt. + /// + bool IsInPrompt + { + get { return textEditor.Column - promptLength - 1 < 0; } + } + + /// + /// Returns true if the user can delete at the current cursor position. + /// + bool CanDelete + { + get + { + if (textEditor.SelectionLength > 0) return SelectionIsDeletable; + else return !IsInReadOnlyRegion; + } + } + + /// + /// Returns true if the user can backspace at the current cursor position. + /// + bool CanBackspace + { + get + { + if (textEditor.SelectionLength > 0) return SelectionIsDeletable; + else + { + int cursorIndex = textEditor.Column - promptLength - 1; + return !IsCurrentLineReadOnly && (cursorIndex > 0 || (cursorIndex == 0 && textEditor.SelectionLength > 0)); + } + } + } + + bool SelectionIsDeletable + { + get + { + return (!textEditor.SelectionIsMultiline + && !IsCurrentLineReadOnly + && (textEditor.SelectionStartColumn - promptLength - 1 >= 0) + && (textEditor.SelectionEndColumn - promptLength - 1 >= 0)); + } + } + + /// + /// The home position is at the start of the line after the prompt. + /// + void MoveToHomePosition() + { + textEditor.Line = textEditor.TotalLines; + textEditor.Column = promptLength + 1; + } + + /// + /// Shows the previous command line in the command line history. + /// + void MoveToPreviousCommandLine() + { + if (commandLineHistory.MovePrevious()) + { + ReplaceCurrentLineTextAfterPrompt(commandLineHistory.Current); + } + } + + /// + /// Shows the next command line in the command line history. + /// + void MoveToNextCommandLine() + { + textEditor.Line = textEditor.TotalLines; + if (commandLineHistory.MoveNext()) + { + ReplaceCurrentLineTextAfterPrompt(commandLineHistory.Current); + } + } + + /// + /// Replaces the current line text after the prompt with the specified text. + /// + void ReplaceCurrentLineTextAfterPrompt(string text) + { + string currentLine = GetCurrentLine(); + textEditor.Replace(promptLength, currentLine.Length, text); + + // Put cursor at end. + textEditor.Column = promptLength + text.Length + 1; + } + } + + public class ErrorReporter : ErrorListener + { + public List Errors = new List(); + + public override void ErrorReported(ScriptSource source, string message, SourceSpan span, int errorCode, Severity severity) + { + Errors.Add(string.Format("{0} (line {1})", message, span.Start.Line)); + } + + public int Count + { + get { return Errors.Count; } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionDataProvider.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionDataProvider.cs new file mode 100644 index 000000000..581c19147 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionDataProvider.cs @@ -0,0 +1,344 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ICSharpCode.AvalonEdit.CodeCompletion; +using Microsoft.Scripting.Hosting.Shell; +using Microsoft.Scripting; +using System.Threading; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace PythonConsoleControl +{ + /// + /// Provides code completion for the Python Console window. + /// + public class PythonConsoleCompletionDataProvider + { + CommandLine commandLine; + internal volatile bool AutocompletionInProgress = false; + + bool excludeCallables; + public bool ExcludeCallables { get { return excludeCallables; } set { excludeCallables = value; } } + + public PythonConsoleCompletionDataProvider(CommandLine commandLine)//IMemberProvider memberProvider) + { + this.commandLine = commandLine; + } + + /// + /// Generates completion data for the specified text. The text should be everything before + /// the dot character that triggered the completion. The text can contain the command line prompt + /// '>>>' as this will be ignored. + /// + public Tuple GenerateCompletionData(string line) + { + List items = new List(); //DefaultCompletionData + + string objectName = string.Empty; + string memberName = string.Empty; + + int lastDelimiterIndex = FindLastDelimiter(line); + + string name = line.Substring(lastDelimiterIndex + 1); + + // A very simple test of callables! + bool isCallable = name.Contains(')'); + + if (excludeCallables && isCallable) return null; + + System.IO.Stream stream = commandLine.ScriptScope.Engine.Runtime.IO.OutputStream; + try + { + AutocompletionInProgress = true; + // Another possibility: + //commandLine.ScriptScope.Engine.Runtime.IO.SetOutput(new System.IO.MemoryStream(), Encoding.UTF8); + //object value = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(name, SourceCodeKind.Expression).Execute(commandLine.ScriptScope); + //IList members = commandLine.ScriptScope.Engine.Operations.GetMemberNames(value); + + var lastWord = GetLastWord(name); + var beforeLastWord = name.Substring(0, name.Length - lastWord.Length); + if (beforeLastWord.EndsWith(".")) + { + objectName = beforeLastWord.Substring(0, beforeLastWord.Length - 1); + memberName = lastWord; + } + else + { + objectName = string.Empty; + memberName = lastWord; + } + + Type type = TryGetType(objectName); + + // Use Reflection for everything except in-built Python types and COM pbjects. + if (type != null && type.Namespace != "IronPython.Runtime" && !type.FullName.Contains("IronPython.NewTypes") && (type.Name != "__ComObject")) + { + PopulateFromCLRType(items, type, objectName); + } + else + { + PopulateFromPythonType(items, objectName); + AutocompletionInProgress = false; + } + } + catch (ThreadAbortException tae) + { + if (tae.ExceptionState is Microsoft.Scripting.KeyboardInterruptException) Thread.ResetAbort(); + } + catch + { + // Do nothing. + } + commandLine.ScriptScope.Engine.Runtime.IO.SetOutput(stream, Encoding.UTF8); + AutocompletionInProgress = false; + return Tuple.Create(items.Cast().ToArray(), objectName, memberName); + } + + protected Type TryGetType(string name) + { + string tryGetType = name + ".GetType()"; + object type = null; + try + { + type = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(tryGetType, SourceCodeKind.Expression).Execute(commandLine.ScriptScope); + } + catch (ThreadAbortException tae) + { + if (tae.ExceptionState is Microsoft.Scripting.KeyboardInterruptException) Thread.ResetAbort(); + } + catch + { + // Do nothing. + } + return type as Type; + } + + protected void PopulateFromCLRType(List items, Type type, string name) + { + List completionsList = new List(); + MethodInfo[] methodInfo = type.GetMethods(); + PropertyInfo[] propertyInfo = type.GetProperties(); + FieldInfo[] fieldInfo = type.GetFields(); + foreach (MethodInfo methodInfoItem in methodInfo) + { + if ((methodInfoItem.IsPublic) + && (methodInfoItem.Name.IndexOf("get_") != 0) && (methodInfoItem.Name.IndexOf("set_") != 0) + && (methodInfoItem.Name.IndexOf("add_") != 0) && (methodInfoItem.Name.IndexOf("remove_") != 0) + && (methodInfoItem.Name.IndexOf("__") != 0)) + completionsList.Add(methodInfoItem.Name); + } + foreach (PropertyInfo propertyInfoItem in propertyInfo) + { + completionsList.Add(propertyInfoItem.Name); + } + foreach (FieldInfo fieldInfoItem in fieldInfo) + { + completionsList.Add(fieldInfoItem.Name); + } + completionsList.Sort(); + string last = ""; + for (int i = completionsList.Count - 1; i > 0; --i) + { + if (completionsList[i] == last) completionsList.RemoveAt(i); + else last = completionsList[i]; + } + foreach (string completion in completionsList) + { + items.Add(new PythonCompletionData(completion, name, commandLine, true)); + } + } + + protected void PopulateFromPythonType(List items, string name) + { + //string dirCommand = "dir(" + objectName + ")"; + string dirCommand = "sorted([m for m in dir(" + name + ") if not m.startswith('__')], key = str.lower) + sorted([m for m in dir(" + name + ") if m.startswith('__')])"; + object value = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(dirCommand, SourceCodeKind.Expression).Execute(commandLine.ScriptScope); + // dir() returns a Python list whose concrete type differs between IronPython forks + // (PythonList on IPY3, List on IPY2); iterate it through the fork-agnostic interface. + foreach (object member in (value as System.Collections.IEnumerable)) + { + bool isInstance = false; + + if (name == string.Empty) // Special case for globals + { + isInstance = TryGetType((string)member) != null; + } + + items.Add(new PythonCompletionData((string)member, name, commandLine, isInstance)); + } + } + + /// + /// Generates completion data for the specified text. The text should be everything before + /// the dot character that triggered the completion. The text can contain the command line prompt + /// '>>>' as this will be ignored. + /// + public void GenerateDescription(string stub, string item, DescriptionUpdateDelegate updateDescription, bool isInstance) + { + System.IO.Stream stream = commandLine.ScriptScope.Engine.Runtime.IO.OutputStream; + string description = ""; + if (!String.IsNullOrEmpty(item)) + { + try + { + AutocompletionInProgress = true; + // Another possibility: + //commandLine.ScriptScope.Engine.Runtime.IO.SetOutput(new System.IO.MemoryStream(), Encoding.UTF8); + //object value = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(item, SourceCodeKind.Expression).Execute(commandLine.ScriptScope); + //description = commandLine.ScriptScope.Engine.Operations.GetDocumentation(value); + string docCommand = ""; + + if (isInstance) + { + if (stub != string.Empty) + { + docCommand = "type(" + stub + ")" + "." + item + ".__doc__"; + } + else + { + docCommand = "type(" + item + ")" + ".__doc__"; + } + } + else + { + if (stub != string.Empty) + { + docCommand = stub + "." + item + ".__doc__"; + } + else + { + docCommand = item + ".__doc__"; + } + } + + object value = commandLine.ScriptScope.Engine.CreateScriptSourceFromString(docCommand, SourceCodeKind.Expression).Execute(commandLine.ScriptScope); + description = (string)value; + AutocompletionInProgress = false; + } + catch (ThreadAbortException tae) + { + if (tae.ExceptionState is Microsoft.Scripting.KeyboardInterruptException) Thread.ResetAbort(); + AutocompletionInProgress = false; + } + catch + { + AutocompletionInProgress = false; + // Do nothing. + } + commandLine.ScriptScope.Engine.Runtime.IO.SetOutput(stream, Encoding.UTF8); + updateDescription(description); + } + } + + private static readonly Regex MATCH_ALL_WORD = new Regex(@"^\w+$"); + private static readonly Regex MATCH_LAST_WORD = new Regex(@"\w+$"); + + private static readonly char[] DELIMITING_CHARS = { ',', '\t', ' ', ':', ';', '+', '-', '=', '*', '/', '&', '|', '^', '%', '~', '<', '>' }; + + static string GetLastWord(string text) + { + return MATCH_LAST_WORD.Match(text).Value; + } + + static int FindLastDelimiter(string text) + { + int lastDelimitingIndex = -1; + + // TODO: handle balanced but malformed cases such as '( [ ) ]' + int lastUnbalancedParenthesisIndex = FindLastUnbalancedChar(text, '(', ')'); + int lastUnbalancedBracketIndex = FindLastUnbalancedChar(text, '[', ']'); + + lastDelimitingIndex = System.Math.Max(lastUnbalancedParenthesisIndex, lastUnbalancedBracketIndex); + + bool insideDoubleQuotedString = false; + bool insideSingleQuotedString = false; + + for (int i = (lastDelimitingIndex + 1); i < text.Length; i++) + { + char c = text[i]; + + // NOTE: rudimentary string detection (doesn't handle escaped quotes or triple quotes!) + if (c == '"' && !insideSingleQuotedString) + { + insideDoubleQuotedString = !insideDoubleQuotedString; + } + else if (c == '\'' && !insideDoubleQuotedString) + { + insideSingleQuotedString = !insideSingleQuotedString; + } + else if (!insideDoubleQuotedString && !insideSingleQuotedString) + { + if (c == '(') + { + int lastClosed = FindLastUnbalancedChar(text.Substring(i+1), '(', ')'); + i = i + 1 + lastClosed; + } + else if (c == '[') + { + int lastClosed = FindLastUnbalancedChar(text.Substring(i+1), '[', ']'); + i = i + 1 + lastClosed; + } + else if (DELIMITING_CHARS.Contains(c)) + { + lastDelimitingIndex = i; + } + } + } + + return lastDelimitingIndex; + } + + static int FindLastUnbalancedChar(string text, char openedChar, char closedChar) + { + int lastIndex = -1; + + bool insideDoubleQuotedString = false; + bool insideSingleQuotedString = false; + var unbalancedIndices = new Stack(); + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + + // NOTE: rudimentary string detection (doesn't handle escaped quotes or triple quotes!) + if (c == '"' && !insideSingleQuotedString) + { + insideDoubleQuotedString = !insideDoubleQuotedString; + } + else if (c == '\'' && !insideDoubleQuotedString) + { + insideSingleQuotedString = !insideSingleQuotedString; + } + else if (!insideDoubleQuotedString && !insideSingleQuotedString) + { + if (c == openedChar) + { + unbalancedIndices.Push(i); + } + else if (c == closedChar) + { + if (unbalancedIndices.Count == 0) + { + lastIndex = i; + } + else + { + unbalancedIndices.Pop(); + } + } + } + } + + if (unbalancedIndices.Count > 0) + { + lastIndex = unbalancedIndices.Pop(); + } + + return lastIndex; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionWindow.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionWindow.cs new file mode 100644 index 000000000..9507c7beb --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleCompletionWindow.cs @@ -0,0 +1,322 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Editing; +using ICSharpCode.AvalonEdit.Rendering; +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Reflection; + +namespace PythonConsoleControl +{ + public delegate void DescriptionUpdateDelegate(string description); + + /// + /// The code completion window. + /// + public class PythonConsoleCompletionWindow : CompletionWindowBase + { + readonly CompletionList completionList = new CompletionList(); + ToolTip toolTip = new ToolTip(); + DispatcherTimer updateDescription; + TimeSpan updateDescriptionInterval; + PythonTextEditor textEditor; + PythonConsoleCompletionDataProvider completionDataProvider; + + /// + /// Gets the completion list used in this completion window. + /// + public CompletionList CompletionList + { + get { return completionList; } + } + + /// + /// Creates a new code completion window. + /// + public PythonConsoleCompletionWindow(TextArea textArea, PythonTextEditor textEditor) + : base(textArea) + { + // keep height automatic + this.completionDataProvider = textEditor.CompletionProvider; + this.textEditor = textEditor; + this.CloseAutomatically = true; + this.SizeToContent = SizeToContent.Height; + this.MaxHeight = 300; + this.Width = 175; + this.Content = completionList; + // prevent user from resizing window to 0x0 + this.MinHeight = 15; + this.MinWidth = 30; + + toolTip.PlacementTarget = this; + toolTip.Placement = PlacementMode.Right; + toolTip.Closed += toolTip_Closed; + + completionList.InsertionRequested += completionList_InsertionRequested; + completionList.SelectionChanged += completionList_SelectionChanged; + AttachEvents(); + + updateDescription = new DispatcherTimer(); + updateDescription.Tick += new EventHandler(completionList_UpdateDescription); + updateDescriptionInterval = TimeSpan.FromSeconds(0.3); + + EventInfo eventInfo = typeof(TextView).GetEvent("ScrollOffsetChanged"); + Delegate methodDelegate = Delegate.CreateDelegate(eventInfo.EventHandlerType, (this as CompletionWindowBase), "TextViewScrollOffsetChanged"); + eventInfo.RemoveEventHandler(this.TextArea.TextView, methodDelegate); + } + + /// + /// Recolor the completion list and its description tooltip to match the host theme. Light + /// is the default WPF styling, so only the dark theme needs explicit brushes. + /// + public void ApplyTheme(bool useDarkTheme) + { + if (!useDarkTheme) + return; + + var background = new SolidColorBrush(Color.FromRgb(0x25, 0x25, 0x26)); + var foreground = new SolidColorBrush(Color.FromRgb(0xD4, 0xD4, 0xD4)); + var border = new SolidColorBrush(Color.FromRgb(0x3F, 0x3F, 0x46)); + var selection = new SolidColorBrush(Color.FromRgb(0x09, 0x4A, 0x77)); + + this.Background = background; + this.Foreground = foreground; + this.BorderBrush = border; + + completionList.Background = background; + completionList.Foreground = foreground; + + // The list's ListBox is created when the CompletionList template is applied, which may + // not have happened yet; style it now if present, otherwise once it loads. + if (completionList.ListBox != null) + StyleListBox(completionList.ListBox, background, foreground, border, selection); + else + completionList.Loaded += (s, e) => + { + if (completionList.ListBox != null) + StyleListBox(completionList.ListBox, background, foreground, border, selection); + }; + + toolTip.Background = background; + toolTip.Foreground = foreground; + toolTip.BorderBrush = border; + } + + static void StyleListBox(ListBox listBox, Brush background, Brush foreground, Brush border, Brush selection) + { + listBox.Background = background; + listBox.Foreground = foreground; + listBox.BorderBrush = border; + // Override the system selection brushes so the highlighted item stays readable on a + // dark background instead of using the default light-blue selection. + listBox.Resources[SystemColors.HighlightBrushKey] = selection; + listBox.Resources[SystemColors.HighlightTextBrushKey] = foreground; + listBox.Resources[SystemColors.InactiveSelectionHighlightBrushKey] = selection; + listBox.Resources[SystemColors.InactiveSelectionHighlightTextBrushKey] = foreground; + } + + #region ToolTip handling + void toolTip_Closed(object sender, RoutedEventArgs e) + { + // Clear content after tooltip is closed. + // We cannot clear is immediately when setting IsOpen=false + // because the tooltip uses an animation for closing. + if (toolTip != null) + toolTip.Content = null; + } + + void completionList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var item = completionList.SelectedItem; + if (item == null) + { + updateDescription.Stop(); + return; + } + else + { + updateDescription.Interval = updateDescriptionInterval; + updateDescription.Start(); + } + } + + void completionList_UpdateDescription(Object sender, EventArgs e) + { + updateDescription.Stop(); + textEditor.UpdateCompletionDescription(); + } + + /// + /// Update the description of the current item. This is typically called from a separate thread from the main UI thread. + /// + internal void UpdateCurrentItemDescription() + { + if (textEditor.StopCompletion()) + { + updateDescription.Interval = updateDescriptionInterval; + updateDescription.Start(); + return; + } + string stub = ""; + string item = ""; + bool isInstance = false; + textEditor.textEditor.Dispatcher.Invoke(new Action(delegate() + { + PythonCompletionData data = (completionList.SelectedItem as PythonCompletionData); + if (data == null || toolTip == null) + return; + stub = data.Stub; + item = data.Text; + isInstance = data.IsInstance; + })); + // Send to the completion thread to generate the description, providing callback. + completionDataProvider.GenerateDescription(stub, item, completionList_WriteDescription, isInstance); + } + + void completionList_WriteDescription(string description) + { + textEditor.textEditor.Dispatcher.Invoke(new Action(delegate() { + if (toolTip != null) + { + if (description != null) + { + toolTip.Content = description; + toolTip.IsOpen = true; + } + else + { + toolTip.IsOpen = false; + } + } + })); + } + + #endregion + + void completionList_InsertionRequested(object sender, EventArgs e) + { + Close(); + // The window must close before Complete() is called. + // If the Complete callback pushes stacked input handlers, we don't want to pop those when the CC window closes. + var item = completionList.SelectedItem; + if (item != null) + item.Complete(this.TextArea, new AnchorSegment(this.TextArea.Document, this.StartOffset, this.EndOffset - this.StartOffset), e); + } + + void AttachEvents() + { + this.TextArea.Caret.PositionChanged += CaretPositionChanged; + this.TextArea.MouseWheel += textArea_MouseWheel; + this.TextArea.PreviewTextInput += textArea_PreviewTextInput; + } + + /// + protected override void DetachEvents() + { + this.TextArea.Caret.PositionChanged -= CaretPositionChanged; + this.TextArea.MouseWheel -= textArea_MouseWheel; + this.TextArea.PreviewTextInput -= textArea_PreviewTextInput; + base.DetachEvents(); + } + + /// + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + if (toolTip != null) + { + toolTip.IsOpen = false; + toolTip = null; + } + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + completionList.HandleKey(e); + } + } + + void textArea_PreviewTextInput(object sender, TextCompositionEventArgs e) + { + e.Handled = RaiseEventPair(this, PreviewTextInputEvent, TextInputEvent, + new TextCompositionEventArgs(e.Device, e.TextComposition)); + } + + void textArea_MouseWheel(object sender, MouseWheelEventArgs e) + { + e.Handled = RaiseEventPair(GetScrollEventTarget(), + PreviewMouseWheelEvent, MouseWheelEvent, + new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)); + } + + UIElement GetScrollEventTarget() + { + if (completionList == null) + return this; + return completionList.ScrollViewer ?? completionList.ListBox ?? (UIElement)completionList; + } + + /// + /// Gets/Sets whether the completion window should close automatically. + /// The default value is true. + /// + public bool CloseAutomatically { get; set; } + + /// + protected override bool CloseOnFocusLost + { + get { return this.CloseAutomatically; } + } + + /// + /// When this flag is set, code completion closes if the caret moves to the + /// beginning of the allowed range. This is useful in Ctrl+Space and "complete when typing", + /// but not in dot-completion. + /// Has no effect if CloseAutomatically is false. + /// + public bool CloseWhenCaretAtBeginning { get; set; } + + void CaretPositionChanged(object sender, EventArgs e) + { + int offset = this.TextArea.Caret.Offset; + if (offset == this.StartOffset) + { + if (CloseAutomatically && CloseWhenCaretAtBeginning) + { + Close(); + } + else + { + completionList.SelectItem(string.Empty); + } + return; + } + if (offset < this.StartOffset || offset > this.EndOffset) + { + if (CloseAutomatically) + { + Close(); + } + } + else + { + TextDocument document = this.TextArea.Document; + if (document != null) + { + completionList.SelectItem(document.GetText(this.StartOffset, offset - this.StartOffset)); + } + } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml new file mode 100644 index 000000000..24a9963b9 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml @@ -0,0 +1,13 @@ + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml.cs new file mode 100644 index 000000000..709f87bc1 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleControl.xaml.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Xml; +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Rendering; + +namespace PythonConsoleControl +{ + /// + /// Interaction logic for PythonConsoleControl.xaml + /// + public partial class IronPythonConsoleControl : UserControl + { + private const string LightHighlightingName = "Python Console Highlighting"; + private const string DarkHighlightingName = "Python Console Dark Highlighting"; + private const string LightHighlightingResource = "PythonConsoleControl.Resources.Python.xshd"; + private const string DarkHighlightingResource = "PythonConsoleControl.Resources.Python-Dark.xshd"; + + private readonly PythonConsolePad _pad; + private Brush _currentForeground; + + /// + /// Perform the action on an already instantiated PythonConsoleHost. + /// + public void WithConsoleHost(Action action) + { + _pad.Host.WhenConsoleCreated(action); + } + + public IronPythonConsoleControl() + { + InitializeComponent(); + _pad = new PythonConsolePad(); + Grid.Children.Add(_pad.Control); + ApplyTheme(false); + } + + public void ApplyTheme(bool useDarkTheme) + { + _currentForeground = GetForegroundBrush(useDarkTheme); + ApplyThemeResources(useDarkTheme); + var highlightingDefinition = GetHighlightingDefinition( + useDarkTheme ? DarkHighlightingResource : LightHighlightingResource, + useDarkTheme ? DarkHighlightingName : LightHighlightingName); + ApplyHighlighting(highlightingDefinition); + + // Force redraw of the text view + _pad.Control.TextArea.TextView.Redraw(); + } + + private Brush GetForegroundBrush(bool useDarkTheme) + { + Brush foregroundBrush = TryFindResource("ThemeConsoleForeground") as Brush; + if (foregroundBrush == null) + { + foregroundBrush = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0xD4, 0xD4, 0xD4)) // #D4D4D4 + : new SolidColorBrush(Colors.Black); + } + return foregroundBrush; + } + + private void ApplyThemeResources(bool useDarkTheme) + { + TextEditor editor = _pad.Control; + + // Try to find resources from the visual tree (parent window) + Brush backgroundBrush = TryFindResource("ThemeConsoleBackground") as Brush; + + // If not found in resources, use hardcoded values based on theme + // Revit dark theme uses blue-gray colors + if (backgroundBrush == null) + { + backgroundBrush = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x1F, 0x2D, 0x3D)) // #1F2D3D - Revit dark blue-gray + : new SolidColorBrush(Colors.White); + } + + // Apply background and foreground + _pad.SetBackground(backgroundBrush); + _pad.SetForeground(_currentForeground); + + // Completion popups are created on demand, so record the theme for the next one. + _pad.SetCompletionTheme(useDarkTheme); + + // Also set the line number margin colors if showing line numbers + if (editor.ShowLineNumbers) + { + var lineNumbersForeground = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x85, 0x85, 0x85)) // #858585 + : new SolidColorBrush(Color.FromRgb(0x99, 0x99, 0x99)); + editor.LineNumbersForeground = lineNumbersForeground; + } + } + + private static IHighlightingDefinition GetHighlightingDefinition(string resourceName, string highlightingName) + { + IHighlightingDefinition existingHighlighting = HighlightingManager.Instance.GetDefinition(highlightingName); + if (existingHighlighting != null) + { + return existingHighlighting; + } + + using (Stream resourceStream = typeof(IronPythonConsoleControl).Assembly.GetManifestResourceStream(resourceName)) + { + if (resourceStream == null) + { + throw new InvalidOperationException($"Could not find embedded resource: {resourceName}"); + } + + using (XmlReader xmlReader = new XmlTextReader(resourceStream)) + { + var highlightingDefinition = ICSharpCode.AvalonEdit.Highlighting.Xshd. + HighlightingLoader.Load(xmlReader, HighlightingManager.Instance); + HighlightingManager.Instance.RegisterHighlighting(highlightingName, new string[] { ".py" }, highlightingDefinition); + return highlightingDefinition; + } + } + } + + private void ApplyHighlighting(IHighlightingDefinition highlightingDefinition) + { + TextEditor editor = _pad.Control; + editor.SyntaxHighlighting = highlightingDefinition; + + IList lineTransformers = editor.TextArea.TextView.LineTransformers; + + // First, remove any existing HighlightingColorizer (including our custom one) + for (int i = lineTransformers.Count - 1; i >= 0; i--) + { + if (lineTransformers[i] is HighlightingColorizer) + { + lineTransformers.RemoveAt(i); + } + } + + // Add our custom colorizer with the output foreground + var newColorizer = new PythonConsoleHighlightingColorizer(highlightingDefinition, editor.Document) + { + OutputForeground = _currentForeground + }; + lineTransformers.Add(newColorizer); + + // Force redraw to apply new colors + editor.TextArea.TextView.Redraw(); + } + + public PythonConsolePad Pad + { + get { return _pad; } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHighlightingColorizer.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHighlightingColorizer.cs new file mode 100644 index 000000000..7d7d43030 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHighlightingColorizer.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Windows.Media; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Rendering; + +namespace PythonConsoleControl +{ + /// + /// Custom colorizer for the Python console that handles both input lines (with syntax highlighting) + /// and output lines (with a configurable foreground color). + /// + public class PythonConsoleHighlightingColorizer : HighlightingColorizer + { + private readonly TextDocument _document; + private Brush _outputForeground; + + /// + /// Gets or sets the foreground brush for output lines (lines not starting with >>> or ...). + /// + public Brush OutputForeground + { + get => _outputForeground; + set => _outputForeground = value; + } + + /// + /// Creates a new PythonConsoleHighlightingColorizer instance. + /// + /// The highlighting definition to use for input lines. + /// The text document. + public PythonConsoleHighlightingColorizer(IHighlightingDefinition highlightingDefinition, TextDocument document) + : base(highlightingDefinition) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + } + + /// + protected override void ColorizeLine(DocumentLine line) + { + if (line.Length == 0) + return; + + string lineString = _document.GetText(line); + + // Check if this is an input line (starts with >>> or ...) + bool isInputLine = lineString.Length >= 3 && + (lineString.StartsWith(">>>") || lineString.StartsWith("...")); + + if (isInputLine) + { + // Apply syntax highlighting to input lines + IHighlighter highlighter = CurrentContext.TextView.Services.GetService(typeof(IHighlighter)) as IHighlighter; + if (highlighter != null) + { + HighlightedLine hl = highlighter.HighlightLine(line.LineNumber); + foreach (HighlightedSection section in hl.Sections) + { + ChangeLinePart(section.Offset, section.Offset + section.Length, + visualLineElement => ApplyColorToElement(visualLineElement, section.Color)); + } + } + } + else + { + // Apply foreground color to output lines (errors, results, banner text, etc.) + if (_outputForeground != null) + { + ChangeLinePart(line.Offset, line.EndOffset, + visualLineElement => visualLineElement.TextRunProperties.SetForegroundBrush(_outputForeground)); + } + } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHost.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHost.cs new file mode 100644 index 000000000..cf88b33f3 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsoleHost.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Text; +using System.Threading; +using IronPython.Hosting; +using IronPython.Runtime; +using Microsoft.Scripting.Hosting; +using Microsoft.Scripting.Hosting.Providers; +using Microsoft.Scripting.Hosting.Shell; + +namespace PythonConsoleControl +{ + public delegate void ConsoleCreatedEventHandler(object sender, EventArgs e); + + + /// + /// Hosts the python console. + /// + public class PythonConsoleHost : ConsoleHost, IDisposable + { + Thread thread; + PythonTextEditor textEditor; + PythonConsole pythonConsole; + + public event ConsoleCreatedEventHandler ConsoleCreated; + + public PythonConsoleHost(PythonTextEditor textEditor) + { + this.textEditor = textEditor; + } + + public PythonConsole Console + { + get { return pythonConsole; } + } + + public PythonTextEditor Editor + { + get { return textEditor; } + } + + protected override Type Provider + { + get { return typeof(PythonContext); } + } + + /// + /// Runs the console host in its own thread. + /// + public void Run() + { + thread = new Thread(RunConsole); + thread.IsBackground = true; + thread.Start(); + } + + public void Dispose() + { + if (pythonConsole != null) + { + pythonConsole.Dispose(); + } + + if (thread != null) + { + thread.Join(); + } + } + + + protected override CommandLine CreateCommandLine() + { + return new PythonCommandLine(); + } + + protected override OptionsParser CreateOptionsParser() + { + return new PythonOptionsParser(); + } + + /// + /// After the engine is created the standard output is replaced with our custom Stream class so we + /// can redirect the stdout to the text editor window. + /// This can be done in this method since the Runtime object will have been created before this method + /// is called. + /// + protected override IConsole CreateConsole(ScriptEngine engine, CommandLine commandLine, ConsoleOptions options) + { + SetOutput(new PythonOutputStream(textEditor)); + pythonConsole = new PythonConsole(textEditor, commandLine); + if (ConsoleCreated != null) ConsoleCreated(this, EventArgs.Empty); + return pythonConsole; + } + + public void WhenConsoleCreated(Action action) + { + if (pythonConsole != null) + { + pythonConsole.WhenConsoleInitialized(() => action(this)); + } + else + { + ConsoleCreated += (sender, args) => WhenConsoleCreated(action); + } + } + + protected virtual void SetOutput(PythonOutputStream stream) + { + Runtime.IO.SetOutput(stream, Encoding.UTF8); + } + + /// + /// Runs the console. + /// + void RunConsole() + { + this.Run(new string[] { "-X:FullFrames" }); + } + + protected override ScriptRuntimeSetup CreateRuntimeSetup() + { + ScriptRuntimeSetup srs = ScriptRuntimeSetup.ReadConfiguration(); + foreach (var langSetup in srs.LanguageSetups) + { + if (langSetup.FileExtensions.Contains(".py")) + { + langSetup.Options["SearchPaths"] = new string[0]; + } + } + return srs; + } + + protected override void ParseHostOptions(string/*!*/[]/*!*/ args) + { + // Python doesn't want any of the DLR base options. + foreach (string s in args) + { + Options.IgnoredArgs.Add(s); + } + } + + protected override void ExecuteInternal() + { + var pc = HostingHelpers.GetLanguageContext(Engine) as PythonContext; + pc.SetModuleState(typeof(ScriptEngine), Engine); + base.ExecuteInternal(); + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsolePad.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsolePad.cs new file mode 100644 index 000000000..8253b1073 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonConsolePad.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2010 Joe Moorhouse + +using ICSharpCode.AvalonEdit; +using System.Windows.Media; + +namespace PythonConsoleControl +{ + public class PythonConsolePad + { + PythonTextEditor pythonTextEditor; + TextEditor textEditor; + PythonConsoleHost host; + + public PythonConsolePad() + { + textEditor = new TextEditor(); + pythonTextEditor = new PythonTextEditor(textEditor); + host = new PythonConsoleHost(pythonTextEditor); + host.Run(); + textEditor.FontFamily = new FontFamily("Consolas"); + textEditor.FontSize = 12; + } + + public TextEditor Control + { + get { return textEditor; } + } + + public PythonConsoleHost Host + { + get { return host; } + } + + public PythonConsole Console + { + get { return host.Console; } + } + + /// + /// Selects the theme used for completion popups created from now on. + /// + public void SetCompletionTheme(bool useDarkTheme) + { + pythonTextEditor.UseDarkCompletionTheme = useDarkTheme; + } + + /// + /// Sets the foreground color for the console text. + /// + public void SetForeground(Brush foreground) + { + textEditor.Foreground = foreground; + textEditor.TextArea.Foreground = foreground; + // Force the TextView to use the new foreground + textEditor.TextArea.TextView.LinkTextForegroundBrush = foreground; + } + + /// + /// Sets the background color for the console. + /// + public void SetBackground(Brush background) + { + textEditor.Background = background; + } + + public void Dispose() + { + host.Dispose(); + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonEditingCommandHandler.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonEditingCommandHandler.cs new file mode 100644 index 000000000..3d159a351 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonEditingCommandHandler.cs @@ -0,0 +1,220 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Input; +using System.Reflection; + +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Editing; + +namespace PythonConsoleControl +{ + /// + /// Commands that only involve the text editor are outsourced to here. + /// + class PythonEditingCommandHandler + { + PythonTextEditor textEditor; + TextArea textArea; + + public PythonEditingCommandHandler(PythonTextEditor textEditor) + { + this.textEditor = textEditor; + this.textArea = textEditor.textArea; + } + + internal static void CanCutOrCopy(object target, CanExecuteRoutedEventArgs args) + { + // HasSomethingSelected for copy and cut commands + TextArea textArea = GetTextArea(target); + if (textArea != null && textArea.Document != null) + { + args.CanExecute = textArea.Options.CutCopyWholeLine || !textArea.Selection.IsEmpty; + args.Handled = true; + } + } + + internal static TextArea GetTextArea(object target) + { + return target as TextArea; + } + + internal static void OnCopy(object target, ExecutedRoutedEventArgs args) + { + TextArea textArea = GetTextArea(target); + if (textArea != null && textArea.Document != null) + { + if (textArea.Selection.IsEmpty && textArea.Options.CutCopyWholeLine) + { + DocumentLine currentLine = textArea.Document.GetLineByNumber(textArea.Caret.Line); + CopyWholeLine(textArea, currentLine); + } + else + { + CopySelectedText(textArea); + } + args.Handled = true; + } + } + + internal static void OnCut(object target, ExecutedRoutedEventArgs args) + { + TextArea textArea = GetTextArea(target); + if (textArea != null && textArea.Document != null) + { + if (textArea.Selection.IsEmpty && textArea.Options.CutCopyWholeLine) + { + DocumentLine currentLine = textArea.Document.GetLineByNumber(textArea.Caret.Line); + CopyWholeLine(textArea, currentLine); + textArea.Document.Remove(currentLine.Offset, currentLine.TotalLength); + } + else + { + CopySelectedText(textArea); + textArea.Selection.ReplaceSelectionWithText(string.Empty); + } + textArea.Caret.BringCaretToView(); + args.Handled = true; + } + } + + internal static void CopySelectedText(TextArea textArea) + { + var data = textArea.Selection.CreateDataObject(textArea); + + try + { + Clipboard.SetDataObject(data, true); + } + catch (ExternalException) + { + // Apparently this exception sometimes happens randomly. + // The MS controls just ignore it, so we'll do the same. + return; + } + + string text = textArea.Selection.GetText(); + text = TextUtilities.NormalizeNewLines(text, Environment.NewLine); + //textArea.OnTextCopied(new TextEventArgs(text)); + } + + internal static void CopyWholeLine(TextArea textArea, DocumentLine line) + { + ISegment wholeLine = new VerySimpleSegment(line.Offset, line.TotalLength); + string text = textArea.Document.GetText(wholeLine); + // Ensure we use the appropriate newline sequence for the OS + text = TextUtilities.NormalizeNewLines(text, Environment.NewLine); + DataObject data = new DataObject(text); + + // Also copy text in HTML format to clipboard - good for pasting text into Word + // or to the SharpDevelop forums. + IHighlighter highlighter = textArea.GetService(typeof(IHighlighter)) as IHighlighter; + HtmlClipboard.SetHtml(data, HtmlClipboard.CreateHtmlFragment(textArea.Document, highlighter, wholeLine, new HtmlOptions(textArea.Options))); + + MemoryStream lineSelected = new MemoryStream(1); + lineSelected.WriteByte(1); + data.SetData(LineSelectedType, lineSelected, false); + + try + { + Clipboard.SetDataObject(data, true); + } + catch (ExternalException) + { + // Apparently this exception sometimes happens randomly. + // The MS controls just ignore it, so we'll do the same. + return; + } + //textArea.OnTextCopied(new TextEventArgs(text)); + } + + internal static ExecutedRoutedEventHandler OnDelete(RoutedUICommand selectingCommand) + { + return (target, args) => + { + TextArea textArea = GetTextArea(target); + if (textArea != null && textArea.Document != null) + { + // call BeginUpdate before running the 'selectingCommand' + // so that undoing the delete does not select the deleted character + using (textArea.Document.RunUpdate()) + { + Type textAreaType = textArea.GetType(); + MethodInfo method; + if (textArea.Selection.IsEmpty) + { + TextViewPosition oldCaretPosition = textArea.Caret.Position; + selectingCommand.Execute(args.Parameter, textArea); + bool hasSomethingDeletable = false; + foreach (ISegment s in textArea.Selection.Segments) + { + method = textAreaType.GetMethod("GetDeletableSegments", BindingFlags.Instance | BindingFlags.NonPublic); + //textArea.GetDeletableSegments(s).Length > 0) + if ((int)method.Invoke(textArea, new Object[]{s}) > 0) + { + hasSomethingDeletable = true; + break; + } + } + if (!hasSomethingDeletable) + { + // If nothing in the selection is deletable; then reset caret+selection + // to the previous value. This prevents the caret from moving through read-only sections. + textArea.Caret.Position = oldCaretPosition; + //textArea.Selection = Selection.Empty; + } + } + method = textAreaType.GetMethod("RemoveSelectedText", BindingFlags.Instance | BindingFlags.NonPublic); + method.Invoke(textArea, new Object[]{}); + //textArea.RemoveSelectedText(); + } + textArea.Caret.BringCaretToView(); + args.Handled = true; + } + }; + } + + internal static void CanDelete(object target, CanExecuteRoutedEventArgs args) + { + // HasSomethingSelected for delete command + TextArea textArea = GetTextArea(target); + if (textArea != null && textArea.Document != null) + { + args.CanExecute = !textArea.Selection.IsEmpty; + args.Handled = true; + } + } + + const string LineSelectedType = "MSDEVLineSelect"; // This is the type VS 2003 and 2005 use for flagging a whole line copy + + struct VerySimpleSegment : ISegment + { + public readonly int Offset, Length; + + int ISegment.Offset { + get { return Offset; } + } + + int ISegment.Length { + get { return Length; } + } + + public int EndOffset { + get { + return Offset + Length; + } + } + + public VerySimpleSegment(int offset, int length) + { + this.Offset = offset; + this.Length = length; + } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonOutputStream.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonOutputStream.cs new file mode 100644 index 000000000..737529fb7 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonOutputStream.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace PythonConsoleControl +{ + public class PythonOutputStream : Stream + { + PythonTextEditor textEditor; + + public PythonOutputStream(PythonTextEditor textEditor) + { + this.textEditor = textEditor; + } + + public override bool CanRead + { + get { return false; } + } + + public override bool CanSeek + { + get { return false; } + } + + public override bool CanWrite + { + get { return true; } + } + + public override long Length + { + get { return 0; } + } + + public override long Position + { + get { return 0; } + set { } + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) + { + return 0; + } + + public override void SetLength(long value) + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return 0; + } + + /// + /// Assumes the bytes are UTF8 and writes them to the text editor. + /// + public override void Write(byte[] buffer, int offset, int count) + { + string text = Encoding.UTF8.GetString(buffer, offset, count); + text = DecodeUnicodeEscapes(text); + textEditor.Write(text); + } + private string DecodeUnicodeEscapes(string input) + { + return Regex.Replace(input, @"\\u[0-9a-fA-F]{4}", match => + { + var hex = match.Value.Substring(2); + int charValue = Convert.ToInt32(hex, 16); + return char.ConvertFromUtf32(charValue); + }); + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/PythonTextEditor.cs b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonTextEditor.cs new file mode 100644 index 000000000..dec095c01 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/PythonTextEditor.cs @@ -0,0 +1,405 @@ +// Copyright (c) 2010 Joe Moorhouse + +using System; +using System.Collections.Generic; +using System.Text; +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Editing; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Windows; +using System.Windows.Threading; +using System.Windows.Input; +using System.Threading; +using System.Diagnostics; + +namespace PythonConsoleControl +{ + /// + /// Interface console to AvalonEdit and handle autocompletion. + /// + public class PythonTextEditor + { + internal TextEditor textEditor; + internal TextArea textArea; + StringBuilder writeBuffer = new StringBuilder(); + volatile bool writeInProgress = false; + PythonConsoleCompletionWindow completionWindow = null; + int completionEventIndex = 0; + int descriptionEventIndex = 1; + WaitHandle[] completionWaitHandles; + AutoResetEvent completionRequestedEvent = new AutoResetEvent(false); + AutoResetEvent descriptionRequestedEvent = new AutoResetEvent(false); + Thread completionThread; + PythonConsoleCompletionDataProvider completionProvider = null; + Action completionDispatcher = new Action((command) => command()); // dummy completion dispatcher + + public PythonTextEditor(TextEditor textEditor) + { + this.textEditor = textEditor; + this.textArea = textEditor.TextArea; + completionWaitHandles = new WaitHandle[] { completionRequestedEvent, descriptionRequestedEvent }; + completionThread = new Thread(new ThreadStart(Completion)); + completionThread.Priority = ThreadPriority.Lowest; + //completionThread.SetApartmentState(ApartmentState.STA); + completionThread.IsBackground = true; + completionThread.Start(); + } + + /// + /// Set the dispatcher to use to force code completion to happen on a specific thread + /// if necessary. + /// + public void SetCompletionDispatcher(Action newDispatcher) + { + completionDispatcher = newDispatcher; + } + + /// + /// Whether completion popups should render with the dark theme. Set by the host control + /// when its theme is applied; read each time a completion window is created. + /// + public bool UseDarkCompletionTheme { get; set; } + + public bool WriteInProgress + { + get { return writeInProgress; } + } + + public ICollection CommandBindings + { + get { return (this.textArea.ActiveInputHandler as TextAreaDefaultInputHandler).CommandBindings; } + } + + public void Write(string text) + { + Write(text, false, true); + } + + Stopwatch sw; + + public void Write(string text, bool allowSynchronous, bool moveToEnd) + { + text = text.Replace("\r\r\n", "\r\n"); // Normalize Windows-style newlines + text = text.Replace("\r\n", "\n"); // Or "\r" if needed + text = text.Replace("\0", ""); // Remove NUL characters + + if (allowSynchronous) + { + if (moveToEnd) + { + MoveToEnd(); + } + PerformTextInput(text); + return; + } + lock (writeBuffer) + { + writeBuffer.Append(text); + } + if (!writeInProgress) + { + writeInProgress = true; + ThreadPool.QueueUserWorkItem(new WaitCallback(CheckAndOutputWriteBuffer), moveToEnd); + sw = Stopwatch.StartNew(); + } + } + + private void CheckAndOutputWriteBuffer(Object stateInfo) + { + bool moveToEnd = (bool)stateInfo; + + AutoResetEvent writeCompletedEvent = new AutoResetEvent(false); + Action action = new Action(delegate() + { + string toWrite; + lock (writeBuffer) + { + toWrite = writeBuffer.ToString(); + writeBuffer.Remove(0, writeBuffer.Length); + //writeBuffer.Clear(); + } + if (moveToEnd) + { + MoveToEnd(); + } + PerformTextInput(toWrite); + writeCompletedEvent.Set(); + }); + + while (true) + { + // Clear writeBuffer and write out. + textArea.Dispatcher.BeginInvoke(action, DispatcherPriority.Normal); + // Check if writeBuffer has refilled in the meantime; if so clear and write out again. + writeCompletedEvent.WaitOne(); + lock (writeBuffer) + { + if (writeBuffer.Length == 0) + { + writeInProgress = false; + break; + } + } + } + } + + private void MoveToEnd() + { + int lineCount = textArea.Document.LineCount; + if (textArea.Caret.Line != lineCount) textArea.Caret.Line = textArea.Document.LineCount; + int column = textArea.Document.Lines[lineCount - 1].Length + 1; + if (textArea.Caret.Column != column) textArea.Caret.Column = column; + } + + private void PerformTextInput(string text) + { + if (text == "\n" || text == "\r\n") + { + string newLine = TextUtilities.GetNewLineFromDocument(textArea.Document, textArea.Caret.Line); + using (textArea.Document.RunUpdate()) + { + textArea.Selection.ReplaceSelectionWithText(newLine); + } + } + else + textArea.Selection.ReplaceSelectionWithText(text); + textArea.Caret.BringCaretToView(); + } + + public int Column + { + get { return textArea.Caret.Column; } + set { textArea.Caret.Column = value; } + } + + /// + /// Gets the current cursor line. + /// + public int Line + { + get { return textArea.Caret.Line; } + set { textArea.Caret.Line = value; } + } + + /// + /// Gets the total number of lines in the text editor. + /// + public int TotalLines + { + get { return textArea.Document.LineCount; } + } + + public delegate string StringAction(); + /// + /// Gets the text for the specified line. + /// + public string GetLine(int index) + { + return (string)textArea.Dispatcher.Invoke(new StringAction(delegate() + { + DocumentLine line = textArea.Document.Lines[index]; + return textArea.Document.GetText(line); + })); + } + + /// + /// Replaces the text at the specified index on the current line with the specified text. + /// + public void Replace(int index, int length, string text) + { + //int currentLine = textArea.Caret.Line - 1; + int currentLine = textArea.Document.LineCount - 1; + int startOffset = textArea.Document.Lines[currentLine].Offset; + textArea.Document.Replace(startOffset + index, length, text); + } + + public event TextCompositionEventHandler TextEntering + { + add { textArea.TextEntering += value; } + remove { textArea.TextEntering -= value; } + } + + public event TextCompositionEventHandler TextEntered + { + add { textArea.TextEntered += value; } + remove { textArea.TextEntered -= value; } + } + + public event KeyEventHandler PreviewKeyDown + { + add { textArea.PreviewKeyDown += value; } + remove { textArea.PreviewKeyDown -= value; } + } + + public int SelectionStart + { + get + { + return textArea.Selection.SurroundingSegment.Offset; + } + } + + public int SelectionLength + { + get + { + return textArea.Selection.Length; + } + } + + public bool SelectionIsMultiline + { + get + { + return textArea.Selection.IsMultiline; + } + } + + public int SelectionStartColumn + { + get + { + int startOffset = textArea.Selection.SurroundingSegment.Offset; + return startOffset - textArea.Document.GetLineByOffset(startOffset).Offset + 1; + } + } + + public int SelectionEndColumn + { + get + { + int endOffset = textArea.Selection.SurroundingSegment.EndOffset; + return endOffset - textArea.Document.GetLineByOffset(endOffset).Offset + 1; + } + } + + public PythonConsoleCompletionDataProvider CompletionProvider + { + get { return completionProvider; } + set { completionProvider = value; } + } + + public Thread CompletionThread + { + get { return completionThread; } + } + + public bool StopCompletion() + { + if (completionProvider.AutocompletionInProgress) + { + // send Ctrl-C abort + completionThread.Abort(new Microsoft.Scripting.KeyboardInterruptException("")); + return true; + } + return false; + } + + public PythonConsoleCompletionWindow CompletionWindow + { + get { return completionWindow; } + } + + public void ShowCompletionWindow() + { + completionRequestedEvent.Set(); + } + + public void UpdateCompletionDescription() + { + descriptionRequestedEvent.Set(); + } + + /// + /// Perform completion actions on the background completion thread. + /// + void Completion() + { + while (true) + { + int action = WaitHandle.WaitAny(completionWaitHandles); + if (action == completionEventIndex && completionProvider != null) BackgroundShowCompletionWindow(); + if (action == descriptionEventIndex && completionProvider != null && completionWindow != null) BackgroundUpdateCompletionDescription(); + } + } + + /// + /// Obtain completions (this runs in its own thread) + /// + internal void BackgroundShowCompletionWindow() //ICompletionItemProvider + { + // provide AvalonEdit with the data: + string itemForCompletion = ""; + textArea.Dispatcher.Invoke(new Action(delegate() + { + DocumentLine line = textArea.Document.Lines[textArea.Caret.Line - 1]; + itemForCompletion = textArea.Document.GetText(line.Offset, textArea.Caret.Column - 1); + })); + + + completionDispatcher.Invoke(new Action(delegate() + { + try + { + var completionInfo = completionProvider.GenerateCompletionData(itemForCompletion); + + if (completionInfo != null) + { + ICompletionData[] completions = completionInfo.Item1; + string objectName = completionInfo.Item2; + string memberName = completionInfo.Item3; + + if (completions.Length > 0) textArea.Dispatcher.BeginInvoke(new Action(delegate() + { + completionWindow = new PythonConsoleCompletionWindow(textArea, this); + completionWindow.ApplyTheme(UseDarkCompletionTheme); + IList data = completionWindow.CompletionList.CompletionData; + foreach (ICompletionData completion in completions) + { + data.Add(completion); + } + completionWindow.Show(); + completionWindow.Closed += delegate + { + completionWindow = null; + }; + + completionWindow.StartOffset -= memberName.Length; + completionWindow.CompletionList.SelectItem(textArea.Document.GetText(completionWindow.StartOffset, memberName.Length)); + })); + } + } + catch (Exception exception) + { + MessageBox.Show(exception.ToString(), "Error"); + } + + })); + } + + internal void BackgroundUpdateCompletionDescription() + { + completionDispatcher.Invoke(new Action(delegate() + { + try + { + completionWindow.UpdateCurrentItemDescription(); + } + catch (Exception exception) + { + MessageBox.Show(exception.ToString(), "Error"); + } + + })); + } + + public void RequestCompletioninsertion(TextCompositionEventArgs e) + { + if (completionWindow != null) completionWindow.CompletionList.RequestInsertion(e); + // if autocompletion still in progress, terminate + StopCompletion(); + } + + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python-Dark.xshd b/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python-Dark.xshd new file mode 100644 index 000000000..760edf7ae --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python-Dark.xshd @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + """ + """ + + + + ''' + ''' + + + + \# + + + + " + " + + + + + + + ' + ' + + + + + + + >>> + ... + + + + and + as + assert + del + exec + global + in + is + lambda + not + or + pass + print + with + + + + break + continue + elif + else + for + if + return + while + yield + import + from + + + + class + + + + def + + + + except + finally + raise + try + + + + \b[\w]+(?=\s*\() + + + + \b0[xX][0-9a-fA-F]+|\b(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?[jJ]?\b + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python.xshd b/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python.xshd new file mode 100644 index 000000000..a8959c993 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Console/Resources/Python.xshd @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + """ + """ + + + + ''' + ''' + + + + \# + + + + " + " + + + + + + + ' + ' + + + + + + + >>> + ... + + + + assert + del + exec + global + lambda + pass + print + with + + + + and + as + in + is + not + or + + + + break + continue + elif + else + for + if + return + while + yield + + + + import + from + + + + class + + + + def + + + + except + finally + raise + try + + + + \b[\w]+(?=\s*\() + + + + \b0[xX][0-9a-fA-F]+|\b(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?[jJ]?\b + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/Directory.Build.targets b/dev/pyRevitLabs.PyRevit.Shell/Directory.Build.targets new file mode 100644 index 000000000..7332d32da --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Directory.Build.targets @@ -0,0 +1,53 @@ + + + + + + + + Debug IPY2712PR;Debug IPY342 + $(Configurations);Release IPY2712PR;Release IPY342 + + + + IPY2712PR + pyRevitLabs. + $(DefineConstants);IPY2712PR + + + + IPY342 + + $(DefineConstants);IPY342 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml b/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml new file mode 100644 index 000000000..c2331f710 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml.cs b/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml.cs new file mode 100644 index 000000000..303b807dd --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/EditorView.xaml.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Xml; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Highlighting.Xshd; +using Microsoft.Win32; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Code editor (AvalonEdit) paired with the interactive IronPython console. The editor's + /// "Run" sends the current selection, or the whole document, to the console, which already + /// executes every statement in a valid Revit API context (set up by the shell launcher). + /// + public partial class EditorView : UserControl { + // The highlighting themes ship as embedded resources from the ported console control; + // the editor registers its own copies so it never clashes with the console's registrations. + const string LightHighlightingResource = "PythonConsoleControl.Resources.Python.xshd"; + const string DarkHighlightingResource = "PythonConsoleControl.Resources.Python-Dark.xshd"; + const string LightHighlightingName = "Python Editor Highlighting"; + const string DarkHighlightingName = "Python Editor Dark Highlighting"; + + string _currentFileName; + bool _isFirstFocus = true; + + public EditorView() { + InitializeComponent(); + textEditor.Text = "# Write Python here and press F5 (or click Run) to execute in the console below."; + } + + /// Console control, exposed so the launcher can wire the engine and dispatch. + public IronPythonConsoleControl ConsoleControl => consoleControl; + + /// AvalonEdit text editor, exposed for advanced hosting scenarios. + public TextEditor TextEditor => textEditor; + + /// Current file path, or null when the buffer has never been saved. + public string CurrentFileName => _currentFileName; + + public void NewFileClick(object sender, RoutedEventArgs e) { + _currentFileName = null; + textEditor.Text = string.Empty; + } + + public void OpenFileClick(object sender, RoutedEventArgs e) { + var dlg = new OpenFileDialog { + CheckFileExists = true, + Filter = "Python Files|*.py|All Files|*.*", + DefaultExt = ".py" + }; + if (dlg.ShowDialog() ?? false) { + _currentFileName = dlg.FileName; + textEditor.Load(_currentFileName); + } + } + + public void SaveFileClick(object sender, RoutedEventArgs e) => SaveFile(); + + public void SaveAsFileClick(object sender, RoutedEventArgs e) { + _currentFileName = null; + SaveFile(); + } + + void SaveFile() { + if (_currentFileName == null) { + var dlg = new SaveFileDialog { + Filter = "Python Files|*.py", + DefaultExt = "py", + AddExtension = true + }; + if (dlg.ShowDialog() ?? false) + _currentFileName = dlg.FileName; + else + return; + } + textEditor.Save(_currentFileName); + } + + public void RunClick(object sender, RoutedEventArgs e) => RunEditorStatements(); + + void RunEditorStatements() { + string statements = textEditor.TextArea.Selection.Length > 0 + ? textEditor.TextArea.Selection.GetText() + : textEditor.Document.Text; + RunStatements(statements); + } + + /// Run arbitrary statements in the interactive console. + public void RunStatements(string statements) { + consoleControl.Pad.Console.RunStatements(statements); + } + + void TextEditor_PreviewKeyDown(object sender, KeyEventArgs e) { + if (e.Key == Key.F5) { + RunEditorStatements(); + e.Handled = true; + } + else if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control) { + if (Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift)) + SaveAsFileClick(sender, e); + else + SaveFile(); + e.Handled = true; + } + else if (e.Key == Key.O && Keyboard.Modifiers == ModifierKeys.Control) { + OpenFileClick(sender, e); + e.Handled = true; + } + else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.Control) { + NewFileClick(sender, e); + e.Handled = true; + } + } + + // Drop the startup hint the first time the editor gains focus, so the user starts from a + // clean buffer without having to manually delete it. + void TextEditor_GotFocus(object sender, RoutedEventArgs e) { + if (_isFirstFocus && string.IsNullOrEmpty(_currentFileName)) { + textEditor.Text = string.Empty; + _isFirstFocus = false; + } + } + + /// + /// Theme the editor surface and the console together so they match the active Revit UI + /// theme. Reuses the syntax-highlighting definitions already embedded in this assembly. + /// + public void ApplyTheme(bool useDarkTheme) { + ShellTheme.Apply(this, useDarkTheme); + ThemeEditor(useDarkTheme); + consoleControl.ApplyTheme(useDarkTheme); + } + + // The stock ToolBar template reserves space for an overflow dropdown that renders as a + // system-colored spot on the themed bar; the shell never overflows, so remove it. + void ToolBar_Loaded(object sender, RoutedEventArgs e) { + var toolBar = (ToolBar)sender; + if (toolBar.Template.FindName("OverflowGrid", toolBar) is FrameworkElement overflowGrid) + overflowGrid.Visibility = Visibility.Collapsed; + if (toolBar.Template.FindName("MainPanelBorder", toolBar) is FrameworkElement mainPanelBorder) + mainPanelBorder.Margin = new Thickness(0); + } + + void ThemeEditor(bool useDarkTheme) { + textEditor.Background = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x1F, 0x2D, 0x3D)) // Revit dark blue-gray + : new SolidColorBrush(Colors.White); + textEditor.Foreground = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0xD4, 0xD4, 0xD4)) + : new SolidColorBrush(Colors.Black); + textEditor.LineNumbersForeground = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x85, 0x85, 0x85)) + : new SolidColorBrush(Color.FromRgb(0x99, 0x99, 0x99)); + + var highlighting = GetEditorHighlighting(useDarkTheme); + if (highlighting != null) + textEditor.SyntaxHighlighting = highlighting; + } + + static IHighlightingDefinition GetEditorHighlighting(bool useDarkTheme) { + string name = useDarkTheme ? DarkHighlightingName : LightHighlightingName; + var existing = HighlightingManager.Instance.GetDefinition(name); + if (existing != null) + return existing; + + string resource = useDarkTheme ? DarkHighlightingResource : LightHighlightingResource; + using (var stream = typeof(EditorView).Assembly.GetManifestResourceStream(resource)) { + if (stream == null) + return null; + using (var reader = new XmlTextReader(stream)) { + var definition = HighlightingLoader.Load(reader, HighlightingManager.Instance); + // Register without file extensions to avoid colliding with the console's + // own ".py" registration; the editor sets SyntaxHighlighting directly. + HighlightingManager.Instance.RegisterHighlighting(name, new string[0], definition); + return definition; + } + } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml b/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml new file mode 100644 index 000000000..60cb9c0aa --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml.cs b/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml.cs new file mode 100644 index 000000000..367513bf3 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/InteractiveEditorWindow.xaml.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Modeless/modal host window for the interactive Python editor + REPL. Mirrors + /// but embeds instead of the + /// bare console control. + /// + public partial class InteractiveEditorWindow : Window, IShellWindow { + public InteractiveEditorWindow() { + InitializeComponent(); + } + + public IronPythonConsoleControl ConsoleControl => editorView.ConsoleControl; + + /// + /// Apply the editor + console theme and match the window background to it, so the editor + /// blends with the active Revit UI theme instead of always rendering dark. + /// + public void ApplyTheme(bool useDarkTheme) { + ShellTheme.Apply(this, useDarkTheme); + editorView.ApplyTheme(useDarkTheme); + Background = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x1F, 0x2D, 0x3D)) // Revit dark blue-gray + : new SolidColorBrush(Colors.White); + } + + /// + /// Keep the editor floating above the Revit window without blocking it. + /// Uses the process main window handle so it stays version-agnostic. + /// + public void SetRevitAsWindowOwner() { + var revitHandle = Process.GetCurrentProcess().MainWindowHandle; + if (revitHandle != System.IntPtr.Zero) + new WindowInteropHelper(this).Owner = revitHandle; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml b/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml new file mode 100644 index 000000000..ad803bfbc --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml.cs b/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml.cs new file mode 100644 index 000000000..1f098ce3c --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/InteractiveShellWindow.xaml.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Modeless host window for the interactive IronPython REPL. + /// + public partial class InteractiveShellWindow : Window, IShellWindow { + public InteractiveShellWindow() { + InitializeComponent(); + // The console control defaults to the light theme in its own constructor; the + // launcher re-applies the matching Revit theme via ApplyTheme once it has resolved + // the active UITheme (see ShellLauncher). + } + + public IronPythonConsoleControl ConsoleControl { + get { return consoleControl; } + } + + /// + /// Apply the console theme and match the window chrome background to it, so the shell + /// blends with the active Revit UI theme instead of always rendering dark. + /// + public void ApplyTheme(bool useDarkTheme) { + ShellTheme.Apply(this, useDarkTheme); + consoleControl.ApplyTheme(useDarkTheme); + Background = useDarkTheme + ? new SolidColorBrush(Color.FromRgb(0x1F, 0x2D, 0x3D)) // Revit dark blue-gray + : new SolidColorBrush(Colors.White); + } + + /// + /// Keep the shell floating above the Revit window without blocking it. + /// Uses the process main window handle so it stays version-agnostic. + /// + public void SetRevitAsWindowOwner() { + var revitHandle = Process.GetCurrentProcess().MainWindowHandle; + if (revitHandle != IntPtr.Zero) + new WindowInteropHelper(this).Owner = revitHandle; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Resources/pyrevit.ico b/dev/pyRevitLabs.PyRevit.Shell/Resources/pyrevit.ico new file mode 100644 index 000000000..0a88b0cc9 Binary files /dev/null and b/dev/pyRevitLabs.PyRevit.Shell/Resources/pyrevit.ico differ diff --git a/dev/pyRevitLabs.PyRevit.Shell/RevitThemeDetector.cs b/dev/pyRevitLabs.PyRevit.Shell/RevitThemeDetector.cs new file mode 100644 index 000000000..1178e3b84 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/RevitThemeDetector.cs @@ -0,0 +1,35 @@ +using System; +using Autodesk.Revit.UI; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Resolves the active Revit UI theme so the shell can match it instead of always + /// rendering dark. UIThemeManager only exists in Revit 2024 and newer, and this + /// assembly is referenced against older Revit API versions too, so the type is reached by + /// reflection. Revit releases that predate the dark theme fall back to light. + /// + internal static class RevitThemeDetector { + public static bool IsDarkTheme(UIApplication uiapp) { + try { + var themeManagerType = Type.GetType("Autodesk.Revit.UI.UIThemeManager, RevitAPIUI"); + if (themeManagerType == null) + return false; + + var currentThemeProp = themeManagerType.GetProperty("CurrentTheme"); + if (currentThemeProp == null) + return false; + + var theme = currentThemeProp.GetValue(null, null); + if (theme == null) + return false; + + // UITheme is an enum; compare by name to stay decoupled from the API version. + return string.Equals(theme.ToString(), "Dark", StringComparison.Ordinal); + } + catch { + // Any reflection/API mismatch means the running Revit has no dark theme support. + return false; + } + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/Shell.cs b/dev/pyRevitLabs.PyRevit.Shell/Shell.cs new file mode 100644 index 000000000..38aee209d --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/Shell.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Windows.Controls; +using Autodesk.Revit.UI; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Public launch entry points for the interactive shell, called from the launcher script. + /// + /// These are deliberately thin and free of IronPython/AvalonEdit references in their + /// parameter lists so the assembly resolver is installed *before* the JIT has to load those + /// dependencies (which live next to this DLL in its engine folder, not on Revit's probing + /// path). The heavy work happens in , whose methods are only + /// JIT-compiled once these run. + /// + /// is the launcher's sys.path, forwarded to the REPL engine so + /// from pyrevit import ... resolves exactly as in a normal script. + /// + public static class Shell { + public static void Modal(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + ShellLauncher.ShowModal(uiapp, searchPaths); + } + + public static void Modeless(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + ShellLauncher.ShowModeless(uiapp, searchPaths); + } + + /// + /// Open the interactive shell with a code editor panel (AvalonEdit) above the REPL, in a + /// modal window. Same engine environment and dispatch as ; the editor's + /// Run sends the selection (or whole file) to the REPL. + /// + public static void ModalEditor(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + ShellLauncher.ShowModalEditor(uiapp, searchPaths); + } + + /// + /// Open the interactive shell with a code editor panel above the REPL, in a modeless + /// window that keeps Revit interactive (statements run through an ExternalEvent). + /// + public static void ModelessEditor(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + ShellLauncher.ShowModelessEditor(uiapp, searchPaths); + } + + /// + /// Build a fully-wired interactive console control for hosting inside a Revit dockable + /// pane (pyRevit registers the pane itself through forms.register_dockable_panel + /// and places this control as the pane's framework element). The engine is configured with + /// the full pyRevit environment and each statement is marshaled through an ExternalEvent so + /// Revit stays interactive while the pane is open. + /// + public static UserControl CreateDockableConsole(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + return ShellLauncher.CreateConfiguredConsole(uiapp, searchPaths); + } + /// + /// Build a fully-wired Python editor (AvalonEdit + console) for hosting inside a Revit + /// dockable pane, mirroring but with the code editor + /// surface on top. The editor's Run executes through the same ExternalEvent-driven dispatch + /// so Revit stays interactive while the pane is open. + /// + public static UserControl CreateDockableEditor(UIApplication uiapp, IList searchPaths) { + ShellAssemblyResolver.Install(); + return ShellLauncher.CreateConfiguredEditor(uiapp, searchPaths); + } + } + + /// + /// Resolves the shell's private dependencies (AvalonEdit, and the forked IronPython/DLR) from + /// the engine folder this assembly was loaded from, which Revit does not probe. + /// + internal static class ShellAssemblyResolver { + static int _installed; + + public static void Install() { + if (Interlocked.Exchange(ref _installed, 1) == 1) + return; + AppDomain.CurrentDomain.AssemblyResolve += Resolve; + } + + static Assembly Resolve(object sender, ResolveEventArgs args) { + var probeDir = Path.GetDirectoryName(typeof(ShellAssemblyResolver).Assembly.Location); + if (string.IsNullOrEmpty(probeDir)) + return null; + + var candidate = Path.Combine(probeDir, new AssemblyName(args.Name).Name + ".dll"); + return File.Exists(candidate) ? Assembly.LoadFrom(candidate) : null; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/ShellLauncher.cs b/dev/pyRevitLabs.PyRevit.Shell/ShellLauncher.cs new file mode 100644 index 000000000..4c5bfd801 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/ShellLauncher.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Windows.Controls; +using System.Windows.Threading; +using Autodesk.Revit.UI; +using Microsoft.Scripting.Hosting; +using Microsoft.Scripting.Hosting.Shell; +using PythonConsoleControl; + +namespace PyRevitLabs.PyRevit.Shell { + /// + /// Common surface the launcher needs from any shell window (console-only or editor): the + /// console control to wire the engine onto, plus theme/owner helpers. Lets the modal/modeless + /// paths be generic over the concrete window type. + /// + internal interface IShellWindow { + IronPythonConsoleControl ConsoleControl { get; } + void ApplyTheme(bool useDarkTheme); + void SetRevitAsWindowOwner(); + } + + /// + /// Builds and shows the interactive shell window and wires its REPL to a valid Revit API + /// context. Reached through so the assembly resolver is installed first. + /// + internal static class ShellLauncher { + public static InteractiveShellWindow ShowModal(UIApplication uiapp, IList searchPaths) + => ShowModalWindow(uiapp, searchPaths); + + public static InteractiveEditorWindow ShowModalEditor(UIApplication uiapp, IList searchPaths) + => ShowModalWindow(uiapp, searchPaths); + + public static InteractiveShellWindow ShowModeless(UIApplication uiapp, IList searchPaths) + => ShowModelessWindow(uiapp, searchPaths); + + public static InteractiveEditorWindow ShowModelessEditor(UIApplication uiapp, IList searchPaths) + => ShowModelessWindow(uiapp, searchPaths); + + static T ShowModalWindow(UIApplication uiapp, IList searchPaths) where T : System.Windows.Window, IShellWindow, new() { + var gui = new T(); + gui.ApplyTheme(RevitThemeDetector.IsDarkTheme(uiapp)); + + // Modal: run typed code on this (the command's) thread. ShowDialog keeps pumping it, + // so every statement executes in the live API context the shell was opened from. + var mainDispatcher = Dispatcher.FromThread(Thread.CurrentThread); + var dispatcher = mainDispatcher; + AttachAndDispatch(gui, uiapp, searchPaths, mainDispatcher, command => RunOnDispatcher(dispatcher, command)); + + gui.SetRevitAsWindowOwner(); + gui.ShowDialog(); + return gui; + } + + static T ShowModelessWindow(UIApplication uiapp, IList searchPaths) where T : System.Windows.Window, IShellWindow, new() { + var gui = new T(); + gui.ApplyTheme(RevitThemeDetector.IsDarkTheme(uiapp)); + + // Modeless: marshal each statement into a valid API context via an ExternalEvent so + // Revit stays interactive while the shell is open. + var mainDispatcher = Dispatcher.FromThread(Thread.CurrentThread); + var commandCompleted = new AutoResetEvent(false); + var handler = new ShellExternalEventDispatcher(gui.ConsoleControl, commandCompleted); + var externalEvent = ExternalEvent.Create(handler); + AttachAndDispatch(gui, uiapp, searchPaths, mainDispatcher, command => { + handler.Enqueue(command); + externalEvent.Raise(); + commandCompleted.WaitOne(); + }); + + gui.Title += " (modeless)"; + gui.SetRevitAsWindowOwner(); + gui.Show(); + return gui; + } + + /// + /// Create a configured console control for a dockable pane. Same environment and modeless + /// dispatch as , but returns the bare control so pyRevit can host + /// it inside its own WPFPanel-based dockable pane. + /// + public static UserControl CreateConfiguredConsole(UIApplication uiapp, IList searchPaths) { + var control = new IronPythonConsoleControl(); + control.ApplyTheme(RevitThemeDetector.IsDarkTheme(uiapp)); + ConfigureControl(control, uiapp, searchPaths, control, Dispatcher.FromThread(Thread.CurrentThread)); + return control; + } + + /// + /// Create a configured editor (AvalonEdit + console) for a dockable pane. Same environment + /// and modeless dispatch as , but returns the bare + /// so pyRevit can host it inside its own WPFPanel-based + /// dockable pane. The editor's Run sends its buffer to the same REPL, so statements run in + /// a valid Revit API context through the ExternalEvent below. + /// + public static EditorView CreateConfiguredEditor(UIApplication uiapp, IList searchPaths) { + var editor = new EditorView(); + editor.ApplyTheme(RevitThemeDetector.IsDarkTheme(uiapp)); + ConfigureControl(editor.ConsoleControl, uiapp, searchPaths, editor, Dispatcher.FromThread(Thread.CurrentThread)); + return editor; + } + + // Shared by the window-based and dockable shells: configure the engine and wire the + // modeless dispatch (ExternalEvent) onto an already-created console control. + static void ConfigureControl(IronPythonConsoleControl consoleControl, UIApplication uiapp, IList searchPaths, object window, Dispatcher mainDispatcher) { + var commandCompleted = new AutoResetEvent(false); + var handler = new ShellExternalEventDispatcher(consoleControl, commandCompleted); + var externalEvent = ExternalEvent.Create(handler); + + consoleControl.WithConsoleHost(host => { + // Wire dispatch first so statements run in a valid Revit API context even if the + // environment setup below throws. + Action dispatch = command => { + handler.Enqueue(command); + externalEvent.Raise(); + commandCompleted.WaitOne(); + }; + host.Console.SetCommandDispatcher(dispatch); + host.Editor.SetCompletionDispatcher(dispatch); + + RunEngineSetupOnMainThread(host, uiapp, searchPaths, mainDispatcher); + host.Console.ScriptScope.SetVariable("__window__", window); + EnsureInteractiveBuiltins(host.Engine, host.Console.ScriptScope); + }); + } + + // Give the console's engine the full pyRevit environment, then wire its REPL dispatcher. + // Dispatch is installed *before* ConfigureEngineViaRuntime so the REPL always lands in a + // valid API context. The environment setup runs on Revit's main UI thread (see + // RunEngineSetupOnMainThread) because InjectBuiltins touches the Ribbon, which only the + // main thread may access. + static void AttachAndDispatch(IShellWindow gui, UIApplication uiapp, IList searchPaths, Dispatcher mainDispatcher, Action dispatch) { + gui.ConsoleControl.WithConsoleHost(host => { + host.Console.SetCommandDispatcher(dispatch); + host.Editor.SetCompletionDispatcher(dispatch); + + RunEngineSetupOnMainThread(host, uiapp, searchPaths, mainDispatcher); + host.Console.ScriptScope.SetVariable("__window__", gui); + EnsureInteractiveBuiltins(host.Engine, host.Console.ScriptScope); + }); + } + + // ConfigureEngineViaRuntime calls InjectBuiltins, which reads ScriptRuntime.UIControl -> + // ComponentManager.Ribbon -> RibbonControl.Tabs. The Ribbon is a WPF object owned by Revit's + // main UI thread, so calling it from the REPL's background thread throws + // InvalidOperationException ("calling thread cannot access this object"). Marshal the setup + // onto the main thread (captured at shell launch) so InjectBuiltins completes normally and + // the reserved builtins get their real values. Any failure is reported to the console. + static void RunEngineSetupOnMainThread(PythonConsoleHost host, UIApplication uiapp, IList searchPaths, Dispatcher mainDispatcher) { + Exception setupError = null; + if (mainDispatcher != null && !mainDispatcher.CheckAccess()) { + mainDispatcher.Invoke(new Action(() => { + try { ConfigureEngineViaRuntime(host.Engine, uiapp, searchPaths); } + catch (Exception ex) { setupError = ex; } + })); + } + else { + // Already on the main thread (or no dispatcher captured): run inline. + try { ConfigureEngineViaRuntime(host.Engine, uiapp, searchPaths); } + catch (Exception ex) { setupError = ex; } + } + if (setupError != null) + host.Console.WriteLine(DescribeSetupError(setupError), Style.Error); + } + + // Unwrap the reflection TargetInvocationException so the real failure (type, message, + // stack) is shown in the shell instead of the generic "target of an invocation" wrapper. + static string DescribeSetupError(Exception ex) { + var real = (ex is TargetInvocationException tie && tie.InnerException != null) + ? tie.InnerException + : ex; + return "pyRevit environment setup failed: [" + + real.GetType().FullName + "] " + real.Message + + Environment.NewLine + real.StackTrace; + } + + // InjectBuiltins can still abort before the reserved pyrevit builtins (e.g. __shiftclick__) + // are set if setup runs off-thread or fails; provide safe defaults for any that are missing + // so user scripts that rely on them work in the interactive shell. Existing values (set by + // InjectBuiltins on the main thread) are left untouched. + static void EnsureInteractiveBuiltins(ScriptEngine engine, ScriptScope scope) { + const string snippet = +"try: __execid__\nexcept NameError: __execid__ = ''\n" + +"try: __timestamp__\nexcept NameError: __timestamp__ = ''\n" + +"try: __cachedengine__\nexcept NameError: __cachedengine__ = False\n" + +"try: __cachedengineid__\nexcept NameError: __cachedengineid__ = None\n" + +"try: __scriptruntime__\nexcept NameError: __scriptruntime__ = None\n" + +"try: __revit__\nexcept NameError: __revit__ = None\n" + +"try: __commanddata__\nexcept NameError: __commanddata__ = None\n" + +"try: __elements__\nexcept NameError: __elements__ = None\n" + +"try: __uibutton__\nexcept NameError: __uibutton__ = None\n" + +"try: __commandpath__\nexcept NameError: __commandpath__ = ''\n" + +"try: __configcommandpath__\nexcept NameError: __configcommandpath__ = ''\n" + +"try: __commandname__\nexcept NameError: __commandname__ = 'Interactive Shell'\n" + +"try: __commandbundle__\nexcept NameError: __commandbundle__ = 'pyRevit Shell'\n" + +"try: __commandextension__\nexcept NameError: __commandextension__ = 'pyRevitCore'\n" + +"try: __commanduniqueid__\nexcept NameError: __commanduniqueid__ = 'pyrevit-interactive-shell'\n" + +"try: __commandcontrolid__\nexcept NameError: __commandcontrolid__ = 'pyrevit-interactive-shell'\n" + +"try: __forceddebugmode__\nexcept NameError: __forceddebugmode__ = False\n" + +"try: __shiftclick__\nexcept NameError: __shiftclick__ = False\n" + +"try: __result__\nexcept NameError: __result__ = {}\n" + +"try: __eventsender__\nexcept NameError: __eventsender__ = None\n" + +"try: __eventargs__\nexcept NameError: __eventargs__ = None\n"; + try { + engine.Execute(snippet, scope); + } + catch { + // Best effort: the REPL still works without these defaults; user scripts that + // reference a missing reserved builtin will surface the NameError as before. + } + } + + // The full builtins live in the loaded per-version pyRevit runtime. This Revit-agnostic + // shell can't reference a specific runtime version at compile time, so reach + // InteractiveEngine by reflection; the engine object is the same DLR-fork identity as the + // loaded runtime, so the call binds cleanly. Shared by the window-based and dockable shells. + internal static void ConfigureEngineViaRuntime(ScriptEngine engine, UIApplication uiapp, IList searchPaths) { + var runtimeAsm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => { + var name = a.GetName().Name; + return name.StartsWith("pyRevitLabs.PyRevit.Runtime", StringComparison.Ordinal) + && !name.Contains("Shared"); + }); + var configure = runtimeAsm + ?.GetType("PyRevitLabs.PyRevit.Runtime.InteractiveEngine") + ?.GetMethod("ConfigureIronPythonEngine", BindingFlags.Public | BindingFlags.Static); + if (configure == null) + throw new InvalidOperationException( + "Could not find pyRevit runtime InteractiveEngine.ConfigureIronPythonEngine; " + + "is the pyRevit runtime loaded?"); + configure.Invoke(null, new object[] { engine, uiapp, searchPaths }); + } + + // Poll the dispatcher operation so a Ctrl+C keyboard interrupt can break a long run. + static void RunOnDispatcher(Dispatcher dispatcher, Action command) { + if (command == null) + return; + var operation = dispatcher.BeginInvoke(DispatcherPriority.Normal, command); + while (operation.Status != DispatcherOperationStatus.Completed) + operation.Wait(TimeSpan.FromSeconds(1)); + } + } + + /// + /// Runs queued REPL statements inside Revit's API context for the modeless and dockable + /// shells. Reports failures back into the owning console control. + /// + public class ShellExternalEventDispatcher : IExternalEventHandler { + readonly IronPythonConsoleControl _consoleControl; + readonly Queue _commands = new Queue(); + readonly AutoResetEvent _commandCompleted; + + public ShellExternalEventDispatcher(IronPythonConsoleControl consoleControl, AutoResetEvent commandCompleted) { + _consoleControl = consoleControl; + _commandCompleted = commandCompleted; + } + + public void Enqueue(Action command) { + _commands.Enqueue(command); + } + + public void Execute(UIApplication app) { + while (_commands.Count > 0) { + var command = _commands.Dequeue(); + try { + command(); + } + catch (Exception ex) { + _consoleControl.WithConsoleHost(host => { + var formatter = host.Engine.GetService(); + host.Console.WriteLine(formatter.FormatException(ex), Style.Error); + }); + } + finally { + _commandCompleted.Set(); + } + } + } + + public string GetName() { + return "pyRevit Interactive Shell"; + } + } +} diff --git a/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml b/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml new file mode 100644 index 000000000..eed8cca57 --- /dev/null +++ b/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + +