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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml.cs b/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml.cs
new file mode 100644
index 000000000..6f51f53b9
--- /dev/null
+++ b/dev/pyRevitLabs.PyRevit.Shell/ShellTitleBar.xaml.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace PyRevitLabs.PyRevit.Shell {
+ ///
+ /// Custom title bar for the borderless shell windows: draggable, double-click to
+ /// maximize/restore, with minimize/maximize/close buttons and the pyRevit icon. Colors come
+ /// from the ShellTheme brushes so it follows the active Revit UI theme.
+ ///
+ public partial class ShellTitleBar : UserControl {
+ Window _window;
+
+ public ShellTitleBar() {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ void OnLoaded(object sender, RoutedEventArgs e) {
+ DetachWindow();
+ _window = Window.GetWindow(this);
+ if (_window != null) {
+ _window.StateChanged += OnWindowStateChanged;
+ UpdateMaximizeButton();
+ }
+ }
+
+ void OnUnloaded(object sender, RoutedEventArgs e) => DetachWindow();
+
+ void DetachWindow() {
+ if (_window != null) {
+ _window.StateChanged -= OnWindowStateChanged;
+ _window = null;
+ }
+ }
+
+ void OnWindowStateChanged(object sender, EventArgs e) => UpdateMaximizeButton();
+
+ void UpdateMaximizeButton() {
+ bool maximized = _window.WindowState == WindowState.Maximized;
+ maximizeButton.Content = maximized ? "" : "";
+ maximizeButton.ToolTip = maximized ? "Restore" : "Maximize";
+ }
+
+ void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
+ if (_window == null)
+ return;
+ if (e.ClickCount == 2) {
+ ToggleMaximize();
+ return;
+ }
+ _window.DragMove();
+ }
+
+ void ToggleMaximize() {
+ _window.WindowState = _window.WindowState == WindowState.Maximized
+ ? WindowState.Normal
+ : WindowState.Maximized;
+ }
+
+ void MinimizeClick(object sender, RoutedEventArgs e) {
+ if (_window != null)
+ _window.WindowState = WindowState.Minimized;
+ }
+
+ void MaximizeClick(object sender, RoutedEventArgs e) {
+ if (_window != null)
+ ToggleMaximize();
+ }
+
+ void CloseClick(object sender, RoutedEventArgs e) {
+ if (_window != null)
+ _window.Close();
+ }
+ }
+}
diff --git a/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.cs b/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.cs
new file mode 100644
index 000000000..670f24625
--- /dev/null
+++ b/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.cs
@@ -0,0 +1,34 @@
+using System.Windows;
+using System.Windows.Media;
+
+namespace PyRevitLabs.PyRevit.Shell {
+ ///
+ /// Swaps the shared shell brushes declared in ShellTheme.xaml between the light and dark
+ /// palettes. Values set on the given root shadow the merged-dictionary defaults, so every
+ /// DynamicResource reference below that root follows the active Revit UI theme.
+ ///
+ internal static class ShellTheme {
+ public static void Apply(FrameworkElement root, bool useDarkTheme) {
+ Set(root, "ShellBarBackground", useDarkTheme, 0x2A3847, 0xF0F0F0);
+ Set(root, "ShellBarForeground", useDarkTheme, 0xD4D4D4, 0x1E1E1E);
+ Set(root, "ShellSplitterBackground", useDarkTheme, 0x2A3847, 0xE0E0E0);
+ Set(root, "ShellRunGlyph", useDarkTheme, 0x6CC26C, 0x3D9142);
+ Set(root, "ShellScrollTrack", useDarkTheme, 0x1F2D3D, 0xF5F5F5);
+ Set(root, "ShellScrollThumb", useDarkTheme, 0x46586B, 0xC8C8C8);
+ Set(root, "ShellScrollThumbHover", useDarkTheme, 0x5C7188, 0xA0A0A0);
+ Set(root, "ShellTitleBarBackground", useDarkTheme, 0x16202C, 0xF0F0F0);
+ Set(root, "ShellTitleBarForeground", useDarkTheme, 0xD4D4D4, 0x1E1E1E);
+ Set(root, "ShellTitleBarButtonHover", useDarkTheme, 0x2A3847, 0xD9D9D9);
+ Set(root, "ShellTitleBarButtonPressed", useDarkTheme, 0x39495C, 0xC8C8C8);
+ Set(root, SystemColors.ControlBrushKey, useDarkTheme, 0x1F2D3D, 0xF5F5F5);
+ }
+
+ static void Set(FrameworkElement root, object key, bool useDarkTheme, int dark, int light) {
+ int rgb = useDarkTheme ? dark : light;
+ var brush = new SolidColorBrush(Color.FromRgb(
+ (byte)(rgb >> 16), (byte)(rgb >> 8), (byte)rgb));
+ brush.Freeze();
+ root.Resources[key] = brush;
+ }
+ }
+}
diff --git a/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.xaml b/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.xaml
new file mode 100644
index 000000000..fab2902ec
--- /dev/null
+++ b/dev/pyRevitLabs.PyRevit.Shell/Themes/ShellTheme.xaml
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj b/dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj
new file mode 100644
index 000000000..4a0a48087
--- /dev/null
+++ b/dev/pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj
@@ -0,0 +1,26 @@
+
+
+ x64
+ net48;net8.0-windows
+ true
+ true
+ true
+ pyRevitLabs.PyRevit.Shell
+ PyRevitLabs.PyRevit.Shell
+ latest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/pyrevit.py b/dev/pyrevit.py
index cd7d8b2f2..32e8bc9e4 100644
--- a/dev/pyrevit.py
+++ b/dev/pyrevit.py
@@ -114,6 +114,7 @@ def print_help(_: Dict[str, str]):
Command(name="build", target="deps", args=[], run=labs.build_deps),
Command(name="build", target="labs", args=["[]"], run=labs.build_labs),
Command(name="build", target="engines", args=["[]"], run=labs.build_engines),
+ Command(name="build", target="shell", args=["[]"], run=labs.build_shell),
Command(name="build", target="autocmp", args=[], run=autoc.build_autocmp),
Command(name="build", target="installers", args=[], run=release.build_installers),
Command(name="build", target="choco", args=[], run=release.build_choco),
diff --git a/dev/scripts/configs.py b/dev/scripts/configs.py
index 680d8d2af..4ef68b4b8 100644
--- a/dev/scripts/configs.py
+++ b/dev/scripts/configs.py
@@ -26,6 +26,7 @@
# root path for non-deployable source files
DEVPATH = op.join(ROOT, "dev")
RUNTIME = op.join(DEVPATH, "pyRevitLabs.PyRevit.Runtime/pyRevitLabs.PyRevit.Runtime.sln")
+SHELL = op.join(DEVPATH, "pyRevitLabs.PyRevit.Shell/pyRevitLabs.PyRevit.Shell.csproj")
LABS = op.join(DEVPATH, "pyRevitLabs/pyRevitLabs.sln")
LABS_CLI = op.join(DEVPATH, "pyRevitLabs/pyRevitClI/pyRevitCLI.csproj")
LABS_DOCTOR = op.join(DEVPATH, "pyRevitLabs/pyRevitDoctor/pyRevitDoctor.csproj")
diff --git a/extensions/pyRevitCore.extension/PythonShellDockablePanel.xaml b/extensions/pyRevitCore.extension/PythonShellDockablePanel.xaml
new file mode 100644
index 000000000..2ffe74c68
--- /dev/null
+++ b/extensions/pyRevitCore.extension/PythonShellDockablePanel.xaml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/bundle.yaml b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/bundle.yaml
new file mode 100644
index 000000000..77b0d829d
--- /dev/null
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/bundle.yaml
@@ -0,0 +1,8 @@
+title:
+ en_us: Python Shell
+tooltip:
+ en_us: >-
+ Open the interactive IronPython 3.4 shell. Type Python and see results immediately, with
+ syntax highlighting and CTRL+SPACE autocompletion. Shift+Click to choose how it opens
+ (modal, modeless, or docked).
+context: zero-doc
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/config.py b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/config.py
new file mode 100644
index 000000000..c1638efcb
--- /dev/null
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/config.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+"""Choose how the pyRevit Python Shell opens: modal, modeless, docked, or with
+a built-in code editor panel (editor variants include a dockable editor pane)."""
+from pyrevit import script, forms
+
+shell_config = script.get_config()
+current_mode = shell_config.get_option("mode", "Modeless")
+
+modes = [
+ "Modeless",
+ "Modeless Editor",
+ "Modal",
+ "Modal Editor",
+ "Docked",
+ "Docked Editor",
+]
+selected = forms.ask_for_one_item(
+ modes,
+ default=current_mode,
+ prompt="How should the Python Shell open?",
+ title="Python Shell Mode",
+)
+
+if selected:
+ shell_config.set_option("mode", selected)
+ script.save_config()
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.dark.png b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.dark.png
new file mode 100644
index 000000000..6f6ddec5b
Binary files /dev/null and b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.dark.png differ
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.png b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.png
new file mode 100644
index 000000000..d45137b4c
Binary files /dev/null and b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/icon.png differ
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/script.py b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/script.py
new file mode 100644
index 000000000..fa39343d7
--- /dev/null
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Python Shell.pushbutton/script.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+"""Open the interactive pyRevit Python shell.
+
+How the shell opens (modal window, modeless window, dockable pane, or the same
+windows with a built-in code editor) is configurable: Shift+Click this button to
+choose the mode.
+"""
+import sys
+import clr
+
+from pyrevit import script, forms
+from System import AppDomain
+from System.IO import Path
+from System.Collections.Generic import List
+
+# Must stay in sync with the dockable pane registered in pyRevitCore.extension/startup.py.
+DOCKABLE_PANEL_ID = "8e2a1f4b-3c57-4d9a-b6e8-7f1a2c3d4e5b"
+
+shell_mode = script.get_config().get_option("mode", "Modeless")
+
+
+def _load_shell():
+ # The shell DLL ships into the active engine folder (the IronPython fork pyRevit runs),
+ # alongside the loaded pyRevitLoader; loading it from there makes the shell use that engine.
+ engine_dir = None
+ for asm in AppDomain.CurrentDomain.GetAssemblies():
+ if asm.GetName().Name == "pyRevitLoader":
+ engine_dir = Path.GetDirectoryName(asm.Location)
+ break
+ clr.AddReferenceToFileAndPath(
+ Path.Combine(engine_dir, "pyRevitLabs.PyRevit.Shell.dll")
+ )
+ from PyRevitLabs.PyRevit.Shell import Shell
+
+ # Forward this engine's sys.path so `from pyrevit import ...` resolves in the shell as here.
+ search_paths = List[str]()
+ for path in sys.path:
+ search_paths.Add(path)
+ return Shell, search_paths
+
+
+if shell_mode in ("Docked", "Docked Editor"):
+ forms.open_dockable_panel(DOCKABLE_PANEL_ID)
+else:
+ shell, paths = _load_shell()
+ if shell_mode == "Modal":
+ shell.Modal(__revit__, paths)
+ elif shell_mode == "Modal Editor":
+ shell.ModalEditor(__revit__, paths)
+ elif shell_mode == "Modeless Editor":
+ shell.ModelessEditor(__revit__, paths)
+ else:
+ shell.Modeless(__revit__, paths)
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/bundle.yaml b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/bundle.yaml
index ebe78ccac..e416502c1 100644
--- a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/bundle.yaml
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/bundle.yaml
@@ -9,6 +9,7 @@ title:
pt_br: pyRevit
layout:
- tools
+ - Python Shell
- ">>>>>"
- About
- Update
diff --git a/extensions/pyRevitCore.extension/startup.py b/extensions/pyRevitCore.extension/startup.py
index 24dc17fc0..ca879349b 100644
--- a/extensions/pyRevitCore.extension/startup.py
+++ b/extensions/pyRevitCore.extension/startup.py
@@ -1,8 +1,12 @@
-"""pyRevit core startup script"""
+"""pyRevit core startup script"""
#pylint: disable=import-error,unused-import,invalid-name
from pyrevit._perf import mark as _perfmark
_perfmark("startup.pyRevitCore:entry")
+import os.path as op
+import sys
+
+from pyrevit import HOST_APP
from pyrevit.coreutils.logger import get_logger
from pyrevit.userconfig import user_config
@@ -15,4 +19,187 @@
import pyrevitcore_api
# mlogger.info("pyRevit Core Routes API is activated")
+
+# dockable interactive python shell ==========================================
+# Registered through pyRevit's own forms.register_dockable_panel so it follows the same lifecycle
+# as every other pyRevit dockable pane. The pane is registered here during startup but hidden by
+# default; the heavy console is only built the first time the pane is shown (see on_loaded). The
+# console control itself is built by the pyRevitLabs.PyRevit.Shell assembly that ships in the
+# active engine folder; it configures the full pyRevit environment and marshals each statement
+# through an ExternalEvent so Revit stays interactive while the pane is open.
+
+PYTHON_SHELL_DOCKABLE_PANEL_ID = "8e2a1f4b-3c57-4d9a-b6e8-7f1a2c3d4e5b"
+
+# Capture the live UIApplication now, while this startup script is executing and the handle is
+# valid. The console is built lazily (deferred to when the pane is first shown), and by then the
+# __revit__ builtin is no longer reliably resolvable from this module, so resolving it late fails.
+_SHELL_UIAPP = HOST_APP.uiapp
+
+
+def _build_dockable_shell_console():
+ """Return a configured Python shell control (console, or editor when configured) for the dockable pane."""
+ import clr
+ from System import AppDomain
+ from System.IO import Path, File
+ from System.Collections.Generic import List
+
+ # The shell DLL lives in the active engine folder next to pyRevitLoader; loading it from
+ # there makes the shell use the engine pyRevit is configured to run.
+ engine_dir = None
+ for asm in AppDomain.CurrentDomain.GetAssemblies():
+ if asm.GetName().Name == "pyRevitLoader":
+ engine_dir = Path.GetDirectoryName(asm.Location)
+ break
+ if engine_dir is None or _SHELL_UIAPP is None:
+ return None
+ shell_dll = Path.Combine(engine_dir, "pyRevitLabs.PyRevit.Shell.dll")
+ if not File.Exists(shell_dll):
+ return None
+ clr.AddReferenceToFileAndPath(shell_dll)
+ from PyRevitLabs.PyRevit.Shell import Shell
+
+ # Forward this engine's sys.path so `from pyrevit import ...` resolves in the shell exactly
+ # as in a normal script.
+ search_paths = List[str]()
+ for path in sys.path:
+ search_paths.Add(path)
+
+ # The dockable pane is shared; pick the editor surface when the user configured a "Docked"
+ # Editor" mode (same "mode" option the Python Shell pushbutton writes). Falls back to the
+ # console-only pane for every other mode.
+ if _get_shell_mode() == "Docked Editor":
+ return Shell.CreateDockableEditor(_SHELL_UIAPP, search_paths)
+ return Shell.CreateDockableConsole(_SHELL_UIAPP, search_paths)
+
+
+def _get_shell_mode():
+ # Mirrors the pushbutton config section ("Python Shell_config") so the dockable pane honors the
+ # same preference the Python Shell button writes.
+ try:
+ section = user_config.get_section("Python Shell_config")
+ if section is None:
+ return "Modeless"
+ return section.get_option("mode", "Modeless")
+ except Exception:
+ return "Modeless"
+
+
+try:
+ from pyrevit import forms
+ from pyrevit.framework import System, Media
+ from pyrevit.revit import ui as rvt_ui
+ from Autodesk.Revit.UI.Events import IdlingEventArgs
+
+ class PythonShellDockablePanel(forms.WPFPanel):
+ panel_title = "pyRevit Python Shell"
+ panel_id = PYTHON_SHELL_DOCKABLE_PANEL_ID
+ panel_source = op.join(op.dirname(__file__), "PythonShellDockablePanel.xaml")
+
+ def __init__(self):
+ # Mark not-built before loading XAML, in case Loaded fires during LoadComponent.
+ self._console = None
+ self._pane_shown = False
+ self._build_idling_handler = None
+ forms.WPFPanel.__init__(self)
+ # Match the pane background to the active Revit UI theme so the shell does not flash
+ # dark on a light-themed Revit; the console control is themed by the shell assembly.
+ self._apply_panel_theme()
+ # Subscribing to Idling must happen in a valid API context; __init__ runs during
+ # register_dockable_panel (called from this startup script), which is one. The handler
+ # builds the console only after the pane is shown, so nothing heavy loads at startup.
+ if _SHELL_UIAPP is not None:
+ self._build_idling_handler = \
+ System.EventHandler[IdlingEventArgs](self._build_on_idling)
+ _SHELL_UIAPP.Idling += self._build_idling_handler
+
+ def on_loaded(self, sender, args):
+ # Record visibility only. The console build needs a Revit API context, which this WPF
+ # callback is not, so the actual build runs from the Idling handler above.
+ self._pane_shown = True
+
+ def _build_on_idling(self, sender, args):
+ # Idling runs in a valid API context, which the shell build requires (it creates an
+ # ExternalEvent). Build once the pane has been shown, then stop listening.
+ if self._console is not None or not self._pane_shown:
+ return
+ if self._build_idling_handler is not None:
+ try:
+ _SHELL_UIAPP.Idling -= self._build_idling_handler
+ except Exception:
+ pass
+ self._build_idling_handler = None
+ self._attach_console()
+
+ def _apply_panel_theme(self):
+ # UIThemeManager only exists in Revit 2024+; older releases have no dark theme and
+ # stay light, so any failure to resolve the theme falls back to light.
+ try:
+ is_dark = rvt_ui.get_current_theme() == rvt_ui.UITheme.Dark
+ except Exception:
+ is_dark = False
+ self.Background = (
+ Media.SolidColorBrush(Media.Color.FromRgb(0x1F, 0x2D, 0x3D))
+ if is_dark
+ else Media.SolidColorBrush(Media.Colors.White)
+ )
+ def _attach_console(self):
+ if self._console is not None:
+ return
+ try:
+ console = _build_dockable_shell_console()
+ if console is not None:
+ self._console = console
+ self.console_host.Children.Add(console)
+ else:
+ self._show_error("Shell console could not be created (no console returned).")
+ except Exception as build_error:
+ import traceback
+ mlogger.exception("Failed to create dockable Python shell console")
+ self._show_error(traceback.format_exc() or str(build_error))
+
+ def _show_error(self, message):
+ # Surface build failures directly in the pane; otherwise the pane just renders empty
+ # and the only trace is in the pyRevit log.
+ try:
+ from pyrevit.framework import Controls
+ block = Controls.TextBlock()
+ block.Text = message
+ block.TextWrapping = System.Windows.TextWrapping.Wrap
+ block.Margin = System.Windows.Thickness(8)
+ self.console_host.Children.Clear()
+ self.console_host.Children.Add(block)
+ except Exception:
+ mlogger.exception("Failed to render dockable Python shell error")
+
+ _hide_idling_handler = None
+
+ def _ensure_shell_pane_hidden(sender, args):
+ # Revit persists pane visibility across sessions, so a pane that was shown before reopens
+ # on startup even though it now registers hidden. Hiding needs a valid API context, which
+ # this Idling tick provides; run once, then stop listening.
+ global _hide_idling_handler
+ if _hide_idling_handler is not None:
+ try:
+ _SHELL_UIAPP.Idling -= _hide_idling_handler
+ except Exception:
+ pass
+ _hide_idling_handler = None
+ try:
+ from Autodesk.Revit.UI import DockablePaneId
+ pane_id = DockablePaneId(System.Guid(PYTHON_SHELL_DOCKABLE_PANEL_ID))
+ _SHELL_UIAPP.GetDockablePane(pane_id).Hide()
+ except Exception:
+ mlogger.debug("Could not hide dockable Python shell pane at startup")
+
+ if not forms.is_registered_dockable_panel(PythonShellDockablePanel):
+ forms.register_dockable_panel(PythonShellDockablePanel, default_visible=False)
+ if _SHELL_UIAPP is not None:
+ _hide_idling_handler = System.EventHandler[IdlingEventArgs](
+ _ensure_shell_pane_hidden
+ )
+ _SHELL_UIAPP.Idling += _hide_idling_handler
+except Exception:
+ mlogger.exception("Failed to register dockable Python shell panel")
+
+
_perfmark("startup.pyRevitCore:exit")
diff --git a/pyrevitlib/pyrevit/revit/events.py b/pyrevitlib/pyrevit/revit/events.py
index 90b7416a5..07a98f8a7 100644
--- a/pyrevitlib/pyrevit/revit/events.py
+++ b/pyrevitlib/pyrevit/revit/events.py
@@ -1,4 +1,4 @@
-"""Revit events handler management."""
+"""Revit events handler management."""
#pylint: disable=unused-argument
from collections import deque
from pyrevit import HOST_APP
@@ -113,14 +113,36 @@ def unregister_exec_handlers(handler_group_id):
def delayed_unregister_exec_handlers(handler_group_id):
HANDLER_UNREGISTERER.handler_group_id = handler_group_id
- HANDLER_UNREGISTERER_EXTEVENT.Raise()
+ ext_event = _get_unregisterer_extevent()
+ if ext_event is not None:
+ ext_event.Raise()
+
+
+def _get_unregisterer_extevent():
+ # The unregisterer external event must be created in a valid Revit API context. It is
+ # normally created at module import (below), but if this module is first imported outside
+ # an API context (e.g. from the interactive pyRevit shell, whose REPL runs statements on a
+ # non-command thread), creation is deferred and retried here on first use.
+ global HANDLER_UNREGISTERER_EXTEVENT
+ if HANDLER_UNREGISTERER_EXTEVENT is None:
+ try:
+ HANDLER_UNREGISTERER_EXTEVENT = UI.ExternalEvent.Create(HANDLER_UNREGISTERER)
+ except Exception:
+ HANDLER_UNREGISTERER_EXTEVENT = None
+ return HANDLER_UNREGISTERER_EXTEVENT
REGISTERED_HANDLERS = {}
HANDLER_UNREGISTERER = \
FuncAsEventHandler(unregister_exec_handlers, purge=False)
-HANDLER_UNREGISTERER_EXTEVENT = \
- UI.ExternalEvent.Create(HANDLER_UNREGISTERER)
+# ExternalEvent.Create requires a standard Revit API context. Importing this module outside
+# such a context (e.g. from the interactive shell) used to crash here, so tolerate the failure
+# and create the event lazily on first use (see _get_unregisterer_extevent) instead.
+try:
+ HANDLER_UNREGISTERER_EXTEVENT = \
+ UI.ExternalEvent.Create(HANDLER_UNREGISTERER)
+except Exception:
+ HANDLER_UNREGISTERER_EXTEVENT = None
def handle(*args): #pylint: disable=no-method-argument
@@ -163,7 +185,23 @@ def schedule(self, func):
if compat.IRONPY:
_HANDLER = _GenericExternalEventHandler()
- _EXTERNAL_EVENT = UI.ExternalEvent.Create(_HANDLER)
+ # See HANDLER_UNREGISTERER_EXTEVENT above: ExternalEvent.Create needs a standard API
+ # context, so tolerate failure at import (e.g. when imported from the interactive shell)
+ # and create lazily on first use via _get_execute_extevent().
+ try:
+ _EXTERNAL_EVENT = UI.ExternalEvent.Create(_HANDLER)
+ except Exception:
+ _EXTERNAL_EVENT = None
+
+
+def _get_execute_extevent():
+ global _EXTERNAL_EVENT
+ if _EXTERNAL_EVENT is None:
+ try:
+ _EXTERNAL_EVENT = UI.ExternalEvent.Create(_HANDLER)
+ except Exception:
+ _EXTERNAL_EVENT = None
+ return _EXTERNAL_EVENT
def execute_in_revit_context(func, *args, **kwargs):
@@ -239,4 +277,6 @@ def _wrapper():
g[k] = old
_HANDLER.schedule(_wrapper)
- _EXTERNAL_EVENT.Raise()
+ _ext_event = _get_execute_extevent()
+ if _ext_event is not None:
+ _ext_event.Raise()