From 8800a14b88b34cfde35e4e9fc0c58d49c6ba767a Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Sun, 27 Sep 2026 16:13:18 -0700 Subject: [PATCH] Add Rider extension Fixes #11 --- .github/workflows/build.yml | 50 ++- .vscode/launch.json | 20 + .vscode/settings.json | 4 +- .vscode/tasks.json | 36 ++ src/Eto.Designer/DesignPanel.cs | 3 +- src/Eto.DevExtension.PreviewHost/HostTheme.cs | 3 +- src/Eto.DevExtension.Rider/.gitignore | 5 + src/Eto.DevExtension.Rider/README.md | 42 ++ src/Eto.DevExtension.Rider/build.gradle.kts | 200 ++++++++++ src/Eto.DevExtension.Rider/gradle.properties | 9 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47623 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + src/Eto.DevExtension.Rider/gradlew | 248 ++++++++++++ src/Eto.DevExtension.Rider/gradlew.bat | 112 ++++++ .../settings.gradle.kts | 6 + .../kotlin/com/picoe/eto/rider/EtoFiles.kt | 55 +++ .../kotlin/com/picoe/eto/rider/EtoSettings.kt | 88 +++++ .../eto/rider/EtoTextMateBundleProvider.kt | 9 + .../kotlin/com/picoe/eto/rider/lsp/EtoLsp.kt | 58 +++ .../picoe/eto/rider/preview/HostLauncher.kt | 177 +++++++++ .../picoe/eto/rider/preview/PreviewHost.kt | 307 +++++++++++++++ .../picoe/eto/rider/preview/PreviewPanel.kt | 372 ++++++++++++++++++ .../eto/rider/preview/PreviewToolWindow.kt | 139 +++++++ .../src/main/resources/META-INF/plugin.xml | 57 +++ .../main/resources/META-INF/pluginIcon.svg | 3 + 25 files changed, 2007 insertions(+), 5 deletions(-) create mode 100644 src/Eto.DevExtension.Rider/.gitignore create mode 100644 src/Eto.DevExtension.Rider/README.md create mode 100644 src/Eto.DevExtension.Rider/build.gradle.kts create mode 100644 src/Eto.DevExtension.Rider/gradle.properties create mode 100644 src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.properties create mode 100755 src/Eto.DevExtension.Rider/gradlew create mode 100644 src/Eto.DevExtension.Rider/gradlew.bat create mode 100644 src/Eto.DevExtension.Rider/settings.gradle.kts create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoFiles.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoSettings.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoTextMateBundleProvider.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/lsp/EtoLsp.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewHost.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt create mode 100644 src/Eto.DevExtension.Rider/src/main/resources/META-INF/plugin.xml create mode 100644 src/Eto.DevExtension.Rider/src/main/resources/META-INF/pluginIcon.svg diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30fb3b1..af41c17 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,8 +92,52 @@ jobs: name: extension-vscode path: artifacts/vscode/*.vsix + build-rider: + + # macOS so the plugin includes the Eto.macOS preview host + runs-on: macos-latest + + steps: + - uses: actions/checkout@v7 + with: + submodules: true + + - name: Setup .NET Core + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + ${{ env.DotNetVersion }} + 8.0.x + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install macOS workload + run: dotnet workload install macos + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + + # caches the Rider download between runs + - uses: gradle/actions/setup-gradle@v6 + + - name: Build plugin + working-directory: src/Eto.DevExtension.Rider + # builds against the oldest supported Rider + run: ./gradlew buildPlugin -PuseLocalRider=false + + - name: Upload plugin + uses: actions/upload-artifact@v7 + with: + name: extension-rider + path: src/Eto.DevExtension.Rider/build/distributions/*.zip + publish: - needs: [ build-windows, build-vscode ] + needs: [ build-windows, build-vscode, build-rider ] runs-on: ubuntu-latest if: (github.event_name == 'release' && github.event.action == 'published' && startsWith(github.ref, 'refs/tags/')) permissions: @@ -114,4 +158,6 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} fail_on_unmatched_files: true - files: extensions/*.vsix + files: | + extensions/*.vsix + extensions/*.zip diff --git a/.vscode/launch.json b/.vscode/launch.json index 6304bc4..5e0792f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,26 @@ "${workspaceFolder}/src/Eto.DevExtension.VSCode/out/**/*.js" ] }, + { + // runs the newest Rider installed, or set riderPath in src/Eto.DevExtension.Rider/gradle.properties + "name": "Eto.DevExtension.Rider", + "type": "node-terminal", + "request": "launch", + "command": "./gradlew runIde", + "windows": { + "command": ".\\gradlew.bat runIde" + }, + "cwd": "${workspaceFolder}/src/Eto.DevExtension.Rider" + }, + { + // needs the Debugger for Java extension + "name": "Eto.DevExtension.Rider (debug)", + "type": "java", + "request": "attach", + "hostName": "localhost", + "port": 5005, + "preLaunchTask": "run-rider-debug" + }, { "name": "Eto.DevExtension.VisualStudio.Windows.2022", "type": "clr", diff --git a/.vscode/settings.json b/.vscode/settings.json index 31a2f03..dea1b6c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,7 @@ "var": { "configuration" : "Debug", "buildProperties" : "/v:Minimal /p:GenerateFullPaths=True /consoleLoggerParameters:NoSummary" - } + }, + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "interactive" } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ce0a7d3..a140242 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -45,6 +45,42 @@ }, "problemMatcher": "$tsc", "group": "build" + }, + { + "label": "build-rider-plugin", + "type": "shell", + "command": "./gradlew", + "windows": { + "command": ".\\gradlew.bat" + }, + "args": [ "buildPlugin" ], + "options": { + "cwd": "${workspaceFolder}/src/Eto.DevExtension.Rider" + }, + "problemMatcher": [], + "group": "build" + }, + { + // waits for a debugger on port 5005 before Rider starts + "label": "run-rider-debug", + "type": "shell", + "command": "./gradlew", + "windows": { + "command": ".\\gradlew.bat" + }, + "args": [ "runIde", "--debug-jvm" ], + "options": { + "cwd": "${workspaceFolder}/src/Eto.DevExtension.Rider" + }, + "isBackground": true, + "problemMatcher": { + "owner": "gradle", + "pattern": { "regexp": "^\\b$" }, + "background": { + "beginsPattern": "Using Rider|No local Rider found", + "endsPattern": "Listening for transport dt_socket at address" + } + } } ], "inputs": [ diff --git a/src/Eto.Designer/DesignPanel.cs b/src/Eto.Designer/DesignPanel.cs index fa55a92..61b1a8a 100755 --- a/src/Eto.Designer/DesignPanel.cs +++ b/src/Eto.Designer/DesignPanel.cs @@ -73,7 +73,8 @@ public static Control GetContent(Control content) // swap out window for a panel so we can add it as a child content = new Panel { - BackgroundColor = window.BackgroundColor, + // Gtk reports a window's colour as transparent, which would leave the form without a background + BackgroundColor = window.BackgroundColor.A > 0 ? window.BackgroundColor : Global.Theme.DesignerPanel, Padding = window.Padding, StyleProvider = window.StyleProvider, MinimumSize = window.MinimumSize, diff --git a/src/Eto.DevExtension.PreviewHost/HostTheme.cs b/src/Eto.DevExtension.PreviewHost/HostTheme.cs index 27fa74f..b183ac2 100644 --- a/src/Eto.DevExtension.PreviewHost/HostTheme.cs +++ b/src/Eto.DevExtension.PreviewHost/HostTheme.cs @@ -22,7 +22,8 @@ Color Get(string name, Color fallback) => public Color SummaryBackground => Get(nameof(SummaryBackground), SystemColors.Control); public Color SummaryForeground => Get(nameof(SummaryForeground), SystemColors.ControlText); public Color DesignerBackground => Get(nameof(DesignerBackground), SystemColors.Control); - public Color DesignerPanel => Get(nameof(DesignerPanel), Color.FromRgb(0xF0F0F0)); + // the platform's window colour, so panels look like forms in dark mode, but Gtk can report it as transparent + public Color DesignerPanel => Get(nameof(DesignerPanel), SystemColors.WindowBackground is { A: > 0 } window ? window : Color.FromRgb(0xF0F0F0)); public IEnumerable AllColors => Enumerable.Empty(); } } diff --git a/src/Eto.DevExtension.Rider/.gitignore b/src/Eto.DevExtension.Rider/.gitignore new file mode 100644 index 0000000..45f7672 --- /dev/null +++ b/src/Eto.DevExtension.Rider/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +.intellijPlatform/ +.kotlin/ +build/ +bin/ diff --git a/src/Eto.DevExtension.Rider/README.md b/src/Eto.DevExtension.Rider/README.md new file mode 100644 index 0000000..20f422e --- /dev/null +++ b/src/Eto.DevExtension.Rider/README.md @@ -0,0 +1,42 @@ +# Eto.Forms Designer for Rider + +Autocompletion, hover documentation and a live preview for [Eto.Forms](https://github.com/picoe/Eto) designer files, +with the same features as the VS Code extension. Needs Rider 2025.1 or newer. + +## Features + +- A live preview of `.xeto`, `.jeto` and `.eto.cs` files in the **Eto Preview** tool window, which appears once you open + one of those files. Open it from its button on the right-hand side, **View | Tool Windows**, or **Open Eto Preview** in + the editor's right-click menu. It follows the file you're editing and redraws as you type and after each build. Drag the corner handle to try other sizes; click the size label to go back to the form's own size +- Pick the platform to draw with from the drop down in the preview, the same as in VS Code. The **Log** tab shows the preview host's output +- Completion of controls, properties, events and values, including your own controls once the project is built +- Hover documentation from the Eto.Forms xml docs +- Syntax highlighting for both file types, using the VS Code extension's grammars + +Requires the [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer. On Windows the preview also needs the +.NET Desktop Runtime, and on Linux GTK 3. + +## Settings + +**Settings | Tools | Eto.Forms Designer** has the same options as VS Code's `eto.*` settings: the `dotnet` executable, +the language server and preview host paths, and the folder of the `Eto.dll` to complete against. Run +**Tools | Restart Eto Language Server** to pick up a different project's Eto version. + +## Building + +Needs a JDK 17 or newer to run Gradle (`JAVA_HOME` or on `PATH`), and the .NET SDK. Gradle downloads the JDK it compiles with. + +```sh +./gradlew runIde # starts Rider with the plugin, in a separate sandbox +./gradlew buildPlugin # zip in build/distributions, installable from Settings | Plugins | Install Plugin from Disk +``` + +Both build the language server and preview hosts first. The Eto.macOS preview host is only built on a Mac with the +.NET macOS workload; without it Mac previews use Eto.Mac64. + +The build uses the newest Rider installed in the usual places for Windows, macOS and Linux, including JetBrains Toolbox, +snap and flatpak installs. Set `riderPath` in `gradle.properties` (or the `RIDER_PATH` environment variable) to use a +specific one. With no Rider installed, Rider `riderVersion` is downloaded instead, which is large. + +From VS Code, run **Eto.DevExtension.Rider** from Run and Debug. **Eto.DevExtension.Rider (debug)** attaches a debugger +to Rider, which needs the Debugger for Java extension. diff --git a/src/Eto.DevExtension.Rider/build.gradle.kts b/src/Eto.DevExtension.Rider/build.gradle.kts new file mode 100644 index 0000000..a43e56c --- /dev/null +++ b/src/Eto.DevExtension.Rider/build.gradle.kts @@ -0,0 +1,200 @@ +import org.jetbrains.intellij.platform.gradle.tasks.PrepareSandboxTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.jetbrains.kotlin.jvm") version "2.4.20" + id("org.jetbrains.intellij.platform") version "2.19.0" +} + +val repoRoot: File = projectDir.parentFile.parentFile +val srcDir = repoRoot.resolve("src") +val vscodeDir = srcDir.resolve("Eto.DevExtension.VSCode") +val isMac = System.getProperty("os.name").startsWith("Mac", ignoreCase = true) +val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + +// keeps the version in step with the other extensions +version = Regex("(.+?)").find(repoRoot.resolve("Directory.Build.props").readText())!!.groupValues[1] + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +/** Build number of the Rider install at [dir], or null when it isn't one. */ +fun riderBuild(dir: File): String? { + val info = listOf(dir.resolve("product-info.json"), dir.resolve("Contents/Resources/product-info.json")).firstOrNull { it.isFile } ?: return null + val text = info.readText() + if (!Regex("\"productCode\"\\s*:\\s*\"RD\"").containsMatchIn(text)) return null + return Regex("\"buildNumber\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) +} + +/** Rider installs under [dir], looking [depth] folders down, where the first level's name must mention Rider when [named]. */ +fun findRiders(dir: File, depth: Int, named: Boolean = true): Sequence> = sequence { + val build = riderBuild(dir) + if (build != null) { + yield(dir to build) + return@sequence + } + if (depth <= 0 || dir.extension == "app") return@sequence + for (child in dir.listFiles().orEmpty().filter { it.isDirectory }) { + if (!named || child.name.contains("rider", ignoreCase = true)) + yieldAll(findRiders(child, depth - 1, named = false)) + } +} + +/** The newest Rider installed in the usual places for this OS, including JetBrains Toolbox installs. */ +fun findRider(): File? { + val home = File(System.getProperty("user.home")) + val candidates = when { + isMac -> listOf( + findRiders(File("/Applications"), 1), + findRiders(home.resolve("Applications"), 1), + // older Toolbox versions + findRiders(home.resolve("Library/Application Support/JetBrains/Toolbox/apps"), 4), + ) + isWindows -> { + val localAppData = System.getenv("LOCALAPPDATA")?.let(::File) ?: home.resolve("AppData/Local") + val programFiles = System.getenv("ProgramFiles")?.let(::File) ?: File("C:/Program Files") + listOf( + // Toolbox + findRiders(localAppData.resolve("Programs"), 1), + // standalone installer + findRiders(programFiles.resolve("JetBrains"), 1), + // older Toolbox versions + findRiders(localAppData.resolve("JetBrains/Toolbox/apps"), 3), + ) + } + else -> listOf( + findRiders(home.resolve(".local/share/JetBrains/Toolbox/apps"), 3), + findRiders(File("/opt"), 2), + findRiders(File("/opt/JetBrains"), 2), + findRiders(File("/usr/share"), 1), + findRiders(File("/usr/local"), 2), + findRiders(File("/snap/rider/current"), 0), + findRiders(File("/var/lib/flatpak/app/com.jetbrains.Rider/current/active/files"), 2, named = false), + findRiders(home.resolve(".local/share/flatpak/app/com.jetbrains.Rider/current/active/files"), 2, named = false), + ) + } + val version = Comparator { a, b -> + val x = a.split('.').map { it.toIntOrNull() ?: 0 } + val y = b.split('.').map { it.toIntOrNull() ?: 0 } + (0 until maxOf(x.size, y.size)).map { (x.getOrElse(it) { 0 }).compareTo(y.getOrElse(it) { 0 }) }.firstOrNull { it != 0 } ?: 0 + } + return candidates.asSequence().flatten().maxWithOrNull(compareBy(version) { it.second })?.first +} + +val riderVersion = providers.gradleProperty("riderVersion").get() +val useLocalRider = providers.gradleProperty("useLocalRider").orNull != "false" +val localRider: File? = if (!useLocalRider) null else (providers.gradleProperty("riderPath").orNull ?: System.getenv("RIDER_PATH")) + ?.takeIf { it.isNotBlank() }?.let(::file) + ?: findRider() + +if (localRider != null) + logger.lifecycle("Using Rider ${riderBuild(localRider) ?: "(unknown version)"} at $localRider") +else if (useLocalRider) + logger.lifecycle("No local Rider found, using Rider $riderVersion. Set riderPath to use an installed one.") +else + logger.lifecycle("Using Rider $riderVersion.") + +dependencies { + intellijPlatform { + if (localRider != null) + local(localRider.path) + else + rider(riderVersion) { useInstaller = false } + bundledPlugin("org.jetbrains.plugins.textmate") + } +} + +// Rider 2025.1 runs on Java 21, even when building against a newer Rider +tasks.withType().configureEach { + compilerOptions.jvmTarget = JvmTarget.JVM_21 +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +intellijPlatform { + pluginConfiguration { + version = project.version.toString() + ideaVersion { + sinceBuild = "251" + untilBuild = provider { null } + } + } + // neither is needed, and both slow every build down + buildSearchableOptions = false + instrumentCode = false +} + +// .NET language server and preview hosts, bundled into the plugin the same way as the VS Code extension + +val dotnetDir = layout.buildDirectory.dir("dotnet") +val dotnetSources = files( + repoRoot.resolve("Directory.Build.props"), + srcDir.resolve("Eto.Designer"), + srcDir.resolve("Eto.DevExtension.LanguageServer"), + srcDir.resolve("Eto.DevExtension.PreviewHost"), + srcDir.resolve("Eto.DevExtension.PreviewHost.macOS"), +).asFileTree.matching { exclude("**/bin/**", "**/obj/**") } + +fun dotnetPublish(name: String, project: String, output: String, vararg args: String) = tasks.register(name) { + group = "dotnet" + inputs.files(dotnetSources).withPropertyName("sources") + outputs.dir(dotnetDir.map { it.dir(output) }) + doFirst { delete(dotnetDir.map { it.dir(output) }) } + commandLine("dotnet", "publish", srcDir.resolve("$project/$project.csproj").path, "-c", "Release", "-o", dotnetDir.get().dir(output).asFile.path, *args) +} + +val publishLanguageServer = dotnetPublish("publishLanguageServer", "Eto.DevExtension.LanguageServer", "server") +val publishPreviewHostWindows = dotnetPublish("publishPreviewHostWindows", "Eto.DevExtension.PreviewHost", "preview/win", "-f", "net8.0-windows") +val publishPreviewHostNet = dotnetPublish("publishPreviewHostNet", "Eto.DevExtension.PreviewHost", "preview/net", "-f", "net8.0") + +// the Eto.macOS host is an app bundle, which only builds on a Mac with the .NET macOS workload +val macAppName = "Eto Preview Host.app" +val macAppBuilt = repoRoot.resolve("artifacts/Eto.DevExtension.PreviewHost.macOS/Release/net10.0-macos/$macAppName") +val buildPreviewHostMac = tasks.register("buildPreviewHostMac") { + group = "dotnet" + onlyIf { isMac } + inputs.files(dotnetSources).withPropertyName("sources") + outputs.dir(macAppBuilt) + // CI packages must always ship it, but locally a missing workload shouldn't stop the rest + isIgnoreExitValue = System.getenv("CI") == null + commandLine("dotnet", "build", srcDir.resolve("Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj").path, "-c", "Release") + doLast { + if (executionResult.get().exitValue != 0) + logger.warn("Could not build the Eto.macOS preview host, so Mac previews will use Eto.Mac64.") + } +} +val copyPreviewHostMac = tasks.register("copyPreviewHostMac") { + group = "dotnet" + dependsOn(buildPreviewHostMac) + onlyIf { isMac && macAppBuilt.exists() } + inputs.dir(macAppBuilt).optional() + val target = dotnetDir.map { it.dir("preview/macos/$macAppName") } + outputs.dir(target) + doFirst { delete(target) } + // ditto keeps the bundle's signature intact + commandLine("ditto", macAppBuilt.path, target.get().asFile.path) +} + +// runIde and the tests each have their own sandbox task +tasks.withType().configureEach { + dependsOn(publishLanguageServer, publishPreviewHostWindows, publishPreviewHostNet, copyPreviewHostMac) + val pluginDir = intellijPlatform.projectName + // not "dotnet", which Rider loads into its own backend as ReSharper plugins + from(dotnetDir) { + exclude("**/*.pdb") + into(pluginDir.map { "$it/tools" }) + } + // the VS Code grammars and language configuration, loaded as a TextMate bundle + from(vscodeDir) { + include("package.json", "language-configuration-*.json", "syntaxes/**") + into(pluginDir.map { "$it/textmate" }) + } +} diff --git a/src/Eto.DevExtension.Rider/gradle.properties b/src/Eto.DevExtension.Rider/gradle.properties new file mode 100644 index 0000000..8815c62 --- /dev/null +++ b/src/Eto.DevExtension.Rider/gradle.properties @@ -0,0 +1,9 @@ +# Oldest Rider supported, and the one downloaded to build against when no local install is found +riderVersion=2025.1 +# Path to a Rider install to build and run with, instead of the newest one found on this machine +#riderPath= +# Set to false to always download riderVersion, e.g. to check the plugin still builds against it +#useLocalRider=false + +kotlin.stdlib.default.dependency=false +org.gradle.caching=true diff --git a/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.jar b/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..5097068a8d375f5bf0d15693fb5b3615c972a801 GIT binary patch literal 47623 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 literal 0 HcmV?d00001 diff --git a/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.properties b/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9b7cb4a --- /dev/null +++ b/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/Eto.DevExtension.Rider/gradlew b/src/Eto.DevExtension.Rider/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/src/Eto.DevExtension.Rider/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/src/Eto.DevExtension.Rider/gradlew.bat b/src/Eto.DevExtension.Rider/gradlew.bat new file mode 100644 index 0000000..03754c4 --- /dev/null +++ b/src/Eto.DevExtension.Rider/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/Eto.DevExtension.Rider/settings.gradle.kts b/src/Eto.DevExtension.Rider/settings.gradle.kts new file mode 100644 index 0000000..2ccacd8 --- /dev/null +++ b/src/Eto.DevExtension.Rider/settings.gradle.kts @@ -0,0 +1,6 @@ +plugins { + // downloads the JDK matching the Rider being built against + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "Eto.DevExtension.Rider" diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoFiles.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoFiles.kt new file mode 100644 index 0000000..b4446db --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoFiles.kt @@ -0,0 +1,55 @@ +package com.picoe.eto.rider + +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFile +import java.io.File +import java.nio.file.Path +import kotlin.io.path.exists + +/** Designer files, and where to find the .NET tools bundled with the plugin. */ +object EtoFiles { + private const val PLUGIN_ID = "com.picoe.eto.designer" + private val designer = Regex("\\.(xeto|jeto)$", RegexOption.IGNORE_CASE) + private val previewable = Regex("\\.(xeto|jeto|eto\\.cs|eto\\.vb)$", RegexOption.IGNORE_CASE) + + /** Files the language server handles. */ + fun isDesigner(file: VirtualFile): Boolean = file.isInLocalFileSystem && designer.containsMatchIn(file.name) + + /** Files the preview can draw. */ + fun isPreviewable(file: VirtualFile): Boolean = file.isInLocalFileSystem && previewable.containsMatchIn(file.name) + + val pluginDir: Path? + get() = PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID))?.pluginPath + + /** A file bundled with the plugin, or null when it isn't there. */ + fun bundled(relative: String): Path? = pluginDir?.resolve(relative)?.takeIf { it.exists() } + + /** @param configured A path from the settings, which wins when set. */ + fun find(configured: String?, bundled: String): Path? { + configured?.trim()?.takeIf { it.isNotEmpty() }?.let { return Path.of(it).takeIf { path -> path.exists() } } + return bundled(bundled) + } + + /** The dotnet executable from the settings, the PATH, or where the installers put it. */ + fun dotnet(): String { + EtoSettings.instance.dotnetPath.takeIf { it.isNotBlank() }?.let { return it.trim() } + PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS("dotnet")?.let { return it.path } + val exe = if (SystemInfo.isWindows) "dotnet.exe" else "dotnet" + val home = System.getProperty("user.home") + // IDEs started from the dock or a desktop launcher don't always get the shell's PATH + val known = listOfNotNull( + System.getenv("DOTNET_ROOT"), + "$home/.dotnet", + if (SystemInfo.isWindows) System.getenv("ProgramFiles")?.let { "$it\\dotnet" } else null, + if (SystemInfo.isMac) "/usr/local/share/dotnet" else null, + if (SystemInfo.isMac) "/opt/homebrew/share/dotnet" else null, + if (SystemInfo.isLinux) "/usr/share/dotnet" else null, + if (SystemInfo.isLinux) "/usr/lib/dotnet" else null, + if (SystemInfo.isLinux) "/snap/dotnet-sdk/current" else null, + ) + return known.map { File(it, exe) }.firstOrNull { it.canExecute() }?.path ?: "dotnet" + } +} diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoSettings.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoSettings.kt new file mode 100644 index 0000000..b6e1e67 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoSettings.kt @@ -0,0 +1,88 @@ +// the replacements for the deprecated LSP API are newer than Rider 2025.1, which is still supported +@file:Suppress("DEPRECATION") + +package com.picoe.eto.rider + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.SimplePersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.DialogPanel +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.picoe.eto.rider.lsp.EtoLspServerSupportProvider + +@Service(Service.Level.APP) +@State(name = "EtoFormsDesigner", storages = [Storage("eto-forms-designer.xml")]) +class EtoSettings : SimplePersistentStateComponent(Options()) { + class Options : BaseState() { + var dotnetPath by string("") + var languageServerPath by string("") + var previewHostPath by string("") + var etoAssemblyPath by string("") + } + + var dotnetPath: String + get() = state.dotnetPath.orEmpty() + set(value) { state.dotnetPath = value.trim() } + var languageServerPath: String + get() = state.languageServerPath.orEmpty() + set(value) { state.languageServerPath = value.trim() } + var previewHostPath: String + get() = state.previewHostPath.orEmpty() + set(value) { state.previewHostPath = value.trim() } + var etoAssemblyPath: String + get() = state.etoAssemblyPath.orEmpty() + set(value) { state.etoAssemblyPath = value.trim() } + + companion object { + val instance: EtoSettings get() = service() + } +} + +/** Settings | Tools | Eto.Forms Designer */ +class EtoSettingsConfigurable : BoundConfigurable("Eto.Forms Designer") { + override fun createPanel(): DialogPanel { + val settings = EtoSettings.instance + val serverKey = { listOf(settings.dotnetPath, settings.languageServerPath, settings.etoAssemblyPath) } + var before = serverKey() + return panel { + row("dotnet executable:") { + textFieldWithBrowseButton(FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()) + .bindText(settings::dotnetPath).align(AlignX.FILL) + .comment("Runs the language server and preview. Uses dotnet from PATH when empty.") + } + row("Language server:") { + textFieldWithBrowseButton(FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()) + .bindText(settings::languageServerPath).align(AlignX.FILL) + .comment("Path to Eto.DevExtension.LanguageServer.dll. Uses the bundled copy when empty.") + } + row("Preview host:") { + textFieldWithBrowseButton(FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor()) + .bindText(settings::previewHostPath).align(AlignX.FILL) + .comment("Path to Eto.DevExtension.PreviewHost.dll. Uses the bundled copy when empty. Not used for Eto.macOS previews.") + } + row("Eto assembly folder:") { + textFieldWithBrowseButton(FileChooserDescriptorFactory.createSingleFolderDescriptor()) + .bindText(settings::etoAssemblyPath).align(AlignX.FILL) + .comment("Folder containing the Eto.dll to complete against. Detected from the project when empty.") + } + onApply { + // the server pins one Eto version per session, so these need a fresh process + val after = serverKey() + if (after != before) { + before = after + for (project in ProjectManager.getInstance().openProjects) + LspServerManager.getInstance(project).stopAndRestartIfNeeded(EtoLspServerSupportProvider::class.java) + } + } + } + } +} diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoTextMateBundleProvider.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoTextMateBundleProvider.kt new file mode 100644 index 0000000..7407a68 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/EtoTextMateBundleProvider.kt @@ -0,0 +1,9 @@ +package com.picoe.eto.rider + +import org.jetbrains.plugins.textmate.api.TextMateBundleProvider + +/** Highlighting for .xeto and .jeto files, from the VS Code extension's grammars. */ +class EtoTextMateBundleProvider : TextMateBundleProvider { + override fun getBundles(): List = + listOfNotNull(EtoFiles.bundled("textmate")?.let { TextMateBundleProvider.PluginBundle("Eto.Forms", it) }) +} diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/lsp/EtoLsp.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/lsp/EtoLsp.kt new file mode 100644 index 0000000..c49154c --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/lsp/EtoLsp.kt @@ -0,0 +1,58 @@ +// the replacements for the deprecated LSP API are newer than Rider 2025.1, which is still supported +@file:Suppress("DEPRECATION") + +package com.picoe.eto.rider.lsp + +import com.google.gson.JsonObject +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerSupportProvider +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor +import com.picoe.eto.rider.EtoFiles +import com.picoe.eto.rider.EtoSettings + +private const val SERVER_DLL = "Eto.DevExtension.LanguageServer.dll" + +class EtoLspServerSupportProvider : LspServerSupportProvider { + override fun fileOpened(project: Project, file: VirtualFile, serverStarter: LspServerSupportProvider.LspServerStarter) { + if (EtoFiles.isDesigner(file)) + serverStarter.ensureServerStarted(EtoLspServerDescriptor(project)) + } +} + +private class EtoLspServerDescriptor(project: Project) : ProjectWideLspServerDescriptor(project, "Eto.Forms Designer") { + override fun isSupportedFile(file: VirtualFile): Boolean = EtoFiles.isDesigner(file) + + override fun getLanguageId(file: VirtualFile): String = file.extension?.lowercase() ?: super.getLanguageId(file) + + override fun createCommandLine(): GeneralCommandLine { + val server = EtoFiles.find(EtoSettings.instance.languageServerPath, "tools/server/$SERVER_DLL") + ?: throw ExecutionException("Could not find $SERVER_DLL. Set its path in Settings | Tools | Eto.Forms Designer.") + return GeneralCommandLine(EtoFiles.dotnet(), server.toString()) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + } + + override fun createInitializationOptions(): Any = JsonObject().apply { + EtoSettings.instance.etoAssemblyPath.takeIf { it.isNotEmpty() }?.let { addProperty("etoAssemblyPath", it) } + } +} + +/** Only one Eto.Forms version is loaded per session, so this picks up a different project's. */ +class RestartLanguageServerAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + LspServerManager.getInstance(project).stopAndRestartIfNeeded(EtoLspServerSupportProvider::class.java) + } +} diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt new file mode 100644 index 0000000..0b809b8 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt @@ -0,0 +1,177 @@ +package com.picoe.eto.rider.preview + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.picoe.eto.rider.EtoFiles +import com.picoe.eto.rider.EtoSettings +import java.io.File +import java.nio.file.Path +import kotlin.io.path.pathString + +private const val HOST_DLL = "Eto.DevExtension.PreviewHost.dll" +private const val MAC_EXECUTABLE = "Eto Preview Host.app/Contents/MacOS/Eto.DevExtension.PreviewHost" + +// scanning projects on every keystroke is too slow, but an edited project should still be noticed +private const val CACHE_MS = 10000L + +const val AUTO = "auto" + +/** A platform the preview can draw with. */ +data class PlatformOption(val id: String, val label: String) { + override fun toString() = label +} + +/** How to start a preview host process. */ +data class HostLaunch( + val command: List, + val env: Map = emptyMap(), + /** Label of the platform it draws with. */ + val platform: String, + /** Shown when the process can't be started. */ + val requirement: String, +) + +private class Platform( + val id: String, + val label: String, + /** Matches a project that uses this platform, from a package or project reference. */ + val reference: Regex, + val launch: () -> HostLaunch?, +) + +/** + * Picks the preview host for a file. The platforms offered depend on the OS and what's installed, + * and Auto picks the first one the solution references, otherwise the OS's usual one. + */ +class HostLauncher(private val project: Project, private val log: (String) -> Unit) { + private val autoCache = mutableMapOf>() + private var lastMessage: String? = null + + /** Platforms available on this machine, best first. */ + fun getPlatforms(): List = getAvailable().map { PlatformOption(it.id, it.label) } + + /** @param choice A platform id, or [AUTO]. */ + fun resolve(fileName: String, choice: String): HostLaunch? { + val platforms = getAvailable() + val platform = platforms.firstOrNull { it.id == choice } ?: detect(fileName, platforms) + return platform?.launch?.invoke() + } + + private fun getAvailable(): List { + val dotnet = EtoFiles.dotnet() + val configured = EtoSettings.instance.previewHostPath + val netHost = { EtoFiles.find(configured, "tools/preview/net/$HOST_DLL") } + val platforms = mutableListOf() + + if (SystemInfo.isWindows) { + val windowsHost = { EtoFiles.find(configured, "tools/preview/win/$HOST_DLL") } + val requirement = "The preview needs the .NET 8 Desktop Runtime (or newer)." + platforms += Platform("Wpf", "WPF", Regex("Eto\\.Platform\\.Wpf\\b|Eto\\.Wpf\\.csproj", RegexOption.IGNORE_CASE)) { withDotnet(dotnet, windowsHost(), "Wpf", "WPF", requirement) } + platforms += Platform("WinForms", "WinForms", Regex("Eto\\.Platform\\.Windows\\b|Eto\\.WinForms\\.csproj", RegexOption.IGNORE_CASE)) { withDotnet(dotnet, windowsHost(), "WinForms", "WinForms", requirement) } + } + if (SystemInfo.isMac) { + EtoFiles.bundled("tools/preview/macos/$MAC_EXECUTABLE")?.let { app -> + // zip extraction doesn't always keep the executable bit + app.toFile().takeIf { !it.canExecute() }?.setExecutable(true) + platforms += Platform("macOS", "macOS", Regex("Eto\\.Platform\\.macOS\\b|Eto\\.macOS\\.csproj", RegexOption.IGNORE_CASE)) { + HostLaunch(listOf(app.pathString), platform = "macOS", requirement = "The Eto.macOS preview host could not be started.") + } + } + platforms += Platform("Mac64", "Mac64", Regex("Eto\\.Platform\\.Mac64\\b|Eto\\.Mac64\\.csproj", RegexOption.IGNORE_CASE)) { + withDotnet(dotnet, netHost(), "Mac64", "Mac64", "The preview needs the .NET 8 runtime (or newer).") + } + } + + findGtk()?.let { env -> + platforms += Platform("Gtk", "Gtk", Regex("Eto\\.Platform\\.Gtk\\b|Eto\\.Gtk\\.csproj", RegexOption.IGNORE_CASE)) { + withDotnet(dotnet, netHost(), "Gtk", "Gtk", "The preview needs the .NET 8 runtime (or newer) and GTK 3.")?.copy(env = env) + } + } + return platforms + } + + private fun detect(fileName: String, platforms: List): Platform? { + val key = File(fileName).parent.orEmpty() + autoCache[key]?.let { (time, id) -> + val cached = platforms.firstOrNull { it.id == id } + if (cached != null && System.currentTimeMillis() - time < CACHE_MS) return cached + } + if (platforms.isEmpty()) return null + + val texts = (findSolutionProjects(fileName) ?: findWorkspaceProjects()).map(::readText) + val referenced = platforms.firstOrNull { platform -> texts.any { platform.reference.containsMatchIn(it) } } + val platform = referenced ?: platforms[0] + autoCache[key] = System.currentTimeMillis() to platform.id + log( + if (referenced != null) "Previewing with ${platform.label}, as the solution uses it." + else "Previewing with ${platform.label}, as the solution doesn't reference an Eto platform available here." + ) + return platform + } + + private fun log(message: String) { + if (message != lastMessage) { + lastMessage = message + log.invoke(message) + } + } + + private fun findWorkspaceProjects(): List { + val base = project.basePath?.let(::File) ?: return emptyList() + return base.walkTopDown() + .onEnter { it.name !in setOf("bin", "obj", "node_modules", ".git", ".idea") } + .filter { it.isFile && Regex("\\.(cs|vb|fs)proj$", RegexOption.IGNORE_CASE).containsMatchIn(it.name) } + .take(500) + .map { it.path } + .toList() + } +} + +private fun withDotnet(dotnet: String, dll: Path?, id: String, label: String, requirement: String): HostLaunch? = + dll?.let { HostLaunch(listOf(dotnet, it.pathString, "--platform", id), platform = label, requirement = requirement) } + +/** GTK 3, and the environment the host needs to find it, or null when it isn't installed. */ +private fun findGtk(): Map? { + if (SystemInfo.isMac) { + // Homebrew (Apple silicon, then Intel) or MacPorts, none of which are on the default library path + val dir = listOf("/opt/homebrew/lib", "/usr/local/lib", "/opt/local/lib").firstOrNull { File(it, "libgtk-3.0.dylib").exists() } + return dir?.let { mapOf("DYLD_FALLBACK_LIBRARY_PATH" to joinPath(it, System.getenv("DYLD_FALLBACK_LIBRARY_PATH"))) } + } + if (SystemInfo.isWindows) { + // where GtkSharp's build installs it, or anywhere on PATH such as MSYS2 + val installed = System.getenv("LOCALAPPDATA")?.let { File(it, "Gtk/3.24.24").path } + val path = System.getenv("PATH").orEmpty() + val dir = (listOfNotNull(installed) + path.split(File.pathSeparator)).firstOrNull { it.isNotEmpty() && File(it, "libgtk-3-0.dll").exists() } + return dir?.let { mapOf("PATH" to joinPath(it, path)) } + } + // the desktop's own toolkit, so assume it's there + return emptyMap() +} + +private fun joinPath(first: String, rest: String?): String = if (rest.isNullOrEmpty()) first else "$first${File.pathSeparator}$rest" + +/** Projects in the nearest solution above the file, or null when there's none. */ +private fun findSolutionProjects(fileName: String): List? { + var dir: File? = File(fileName).parentFile + while (dir != null) { + val solution = dir.listFiles().orEmpty().firstOrNull { Regex("\\.slnx?$", RegexOption.IGNORE_CASE).containsMatchIn(it.name) } + if (solution != null) { + val pattern = if (solution.name.endsWith(".slnx", ignoreCase = true)) + Regex("? = null + // hosts that couldn't start, usually for want of a runtime, so they aren't tried again + private val unavailable = mutableSetOf>() + private val redrawListeners = ContainerUtil.createLockFreeCopyOnWriteList<() -> Unit>() + + val log: PreviewLog get() = project.service() + val launcher = HostLauncher(project) { log.appendLine(it) } + + /** Called after the project is rebuilt, so previews can be redrawn. */ + fun onRedraw(parent: Disposable, listener: () -> Unit) { + redrawListeners.add(listener) + Disposer.register(parent) { redrawListeners.remove(listener) } + } + + fun render(request: RenderRequest, platform: String): CompletableFuture = + CompletableFuture.supplyAsync({ renderNow(request, platform) }, executor) + + override fun dispose() { + executor.shutdownNow() + stop() + } + + private fun renderNow(request: RenderRequest, platform: String): RenderResult { + // a second try covers a host that exited after a rebuild, or that serves another project + for (attempt in 0 until 2) { + val launch = launcher.resolve(request.fileName, platform) + ?: return error("Could not find the Eto preview host. Set its path in Settings | Tools | Eto.Forms Designer.") + val connection = getConnection(launch) + ?: return error("${launch.requirement} See the Log tab for details.") + + val params = JsonObject().apply { + addProperty("fileName", request.fileName) + addProperty("text", request.text) + request.width?.let { addProperty("width", it) } + request.height?.let { addProperty("height", it) } + addProperty("scale", request.scale) + add("assemblies", JsonNull.INSTANCE) + } + try { + val result = connection.request("preview/render", params).get(RENDER_TIMEOUT_MS, TimeUnit.MILLISECONDS) as? JsonObject + ?: return error("The preview host returned nothing.") + if (result.bool("restartRequired")) { + stop() + continue + } + val error = result.getAsJsonObject("error") + return RenderResult( + image = result.string("image"), + width = result.int("width"), + height = result.int("height"), + errorMessage = error?.string("message"), + errorDetails = error?.string("details"), + platform = launch.platform, + ) + } catch (e: TimeoutException) { + stop() + return error("The preview took too long to draw, so it was stopped.") + } catch (e: ExecutionException) { + stop() + if (attempt > 0) return error("The preview host stopped unexpectedly.", e.cause?.toString()) + } + } + return error("The preview host could not load the project.") + } + + private fun getConnection(launch: HostLaunch): RpcConnection? { + val key = launch.command + val running = connection + if (launchKey == key && running != null && process?.isAlive == true) return running + if (key in unavailable) return null + stop() + launchKey = key + return start(launch, key) + } + + private fun start(launch: HostLaunch, key: List): RpcConnection? { + try { + val child = GeneralCommandLine(launch.command) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + .withEnvironment(launch.env) + .createProcess() + process = child + pump(child.errorStream) + + val rpc = RpcConnection(child.inputStream, child.outputStream) { method, params -> + when (method) { + "preview/restart" -> { + log.appendLine("Project rebuilt, restarting the preview host.") + redrawListeners.forEach { it() } + } + "window/logMessage" -> params?.string("message")?.let { log.appendLine(it) } + } + } + connection = rpc + rpc.request("initialize", JsonObject().apply { addProperty("processId", ProcessHandle.current().pid()) }).get(RENDER_TIMEOUT_MS, TimeUnit.MILLISECONDS) + rpc.notify("initialized", JsonObject()) + return rpc + } catch (e: Exception) { + unavailable.add(key) + log.appendLine("Could not start the Eto preview host using \"${launch.command.first()}\": ${(e as? ExecutionException)?.cause ?: e}") + stop() + return null + } + } + + private fun pump(stream: InputStream) { + AppExecutorUtil.getAppExecutorService().execute { + try { + stream.bufferedReader().forEachLine { log.appendLine(it) } + } catch (_: Exception) { + } + } + } + + private fun stop() { + val rpc = connection + val child = process + connection = null + process = null + launchKey = null + rpc?.close() + child?.destroy() + } +} + +private fun error(message: String, details: String? = null) = RenderResult(errorMessage = message, errorDetails = details ?: message) + +private fun JsonObject.string(name: String): String? = get(name)?.takeIf { it.isJsonPrimitive }?.asString +private fun JsonObject.int(name: String): Int? = get(name)?.takeIf { it.isJsonPrimitive }?.asInt +private fun JsonObject.bool(name: String): Boolean = get(name)?.takeIf { it.isJsonPrimitive }?.asBoolean == true + +/** JSON-RPC with LSP style Content-Length framing, as spoken by the preview host. */ +private class RpcConnection( + input: InputStream, + private val output: OutputStream, + private val onNotification: (String, JsonObject?) -> Unit, +) { + private val nextId = AtomicInteger() + private val pending = ConcurrentHashMap>() + @Volatile + private var closed = false + + init { + val reader = BufferedInputStream(input) + AppExecutorUtil.getAppExecutorService().execute { + try { + while (!closed) { + val message = read(reader) ?: break + dispatch(message) + } + } catch (e: Exception) { + if (!closed) logger().info("Eto preview host connection closed", e) + } + fail(IllegalStateException("The preview host exited.")) + } + } + + fun request(method: String, params: JsonElement): CompletableFuture { + val id = nextId.incrementAndGet() + val future = CompletableFuture() + pending[id] = future + try { + write(JsonObject().apply { + addProperty("jsonrpc", "2.0") + addProperty("id", id) + addProperty("method", method) + add("params", params) + }) + } catch (e: Exception) { + pending.remove(id) + future.completeExceptionally(e) + } + if (closed) fail(IllegalStateException("The preview host exited.")) + return future + } + + fun notify(method: String, params: JsonElement) { + write(JsonObject().apply { + addProperty("jsonrpc", "2.0") + addProperty("method", method) + add("params", params) + }) + } + + fun close() { + closed = true + fail(IllegalStateException("The preview host was stopped.")) + try { + output.close() + } catch (_: Exception) { + } + } + + private fun fail(error: Exception) { + closed = true + for (id in pending.keys.toList()) + pending.remove(id)?.completeExceptionally(error) + } + + private fun dispatch(message: JsonObject) { + val method = message.get("method")?.takeIf { it.isJsonPrimitive }?.asString + val id = message.get("id")?.takeIf { it.isJsonPrimitive }?.asInt + when { + method == null && id != null -> { + val future = pending.remove(id) ?: return + val error = message.getAsJsonObject("error") + if (error != null) future.completeExceptionally(IllegalStateException(error.get("message")?.asString ?: error.toString())) + else future.complete(message.get("result")) + } + method != null && id == null -> onNotification(method, message.get("params") as? JsonObject) + // the host has no requests for us, but mustn't be left waiting + method != null -> write(JsonObject().apply { + addProperty("jsonrpc", "2.0") + addProperty("id", id) + add("error", JsonObject().apply { + addProperty("code", -32601) + addProperty("message", "Method not found: $method") + }) + }) + } + } + + @Synchronized + private fun write(message: JsonObject) { + val body = message.toString().toByteArray(StandardCharsets.UTF_8) + output.write("Content-Length: ${body.size}\r\n\r\n".toByteArray(StandardCharsets.US_ASCII)) + output.write(body) + output.flush() + } + + private fun read(input: InputStream): JsonObject? { + var length = -1 + while (true) { + val line = readLine(input) ?: return null + if (line.isEmpty()) break + if (line.startsWith("Content-Length:", ignoreCase = true)) + length = line.substring("Content-Length:".length).trim().toIntOrNull() ?: -1 + } + if (length < 0) return JsonObject() + val body = input.readNBytes(length) + if (body.size < length) return null + return JsonParser.parseString(String(body, StandardCharsets.UTF_8)).asJsonObject + } + + private fun readLine(input: InputStream): String? { + val line = ByteArrayOutputStream() + while (true) { + val b = input.read() + if (b < 0) return null + if (b == '\n'.code) break + if (b != '\r'.code) line.write(b) + } + return line.toString(StandardCharsets.US_ASCII) + } +} diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt new file mode 100644 index 0000000..96bb092 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt @@ -0,0 +1,372 @@ +package com.picoe.eto.rider.preview + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.listCellRenderer.textListCellRenderer +import com.intellij.util.Alarm +import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.picoe.eto.rider.EtoFiles +import java.awt.BasicStroke +import java.awt.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Point +import java.awt.Rectangle +import java.awt.RenderingHints +import java.awt.event.HierarchyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.geom.Ellipse2D +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.util.Base64 +import javax.imageio.ImageIO +import javax.swing.JPanel +import javax.swing.JTextArea + +private const val REFRESH_DELAY_MS = 500 + +/** + * The preview in the Eto Preview tool window, which follows the selected designer file and redraws as it is edited. + */ +class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Disposable { + private val host = project.service() + private val state = project.service() + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + private val platforms = CollectionComboBoxModel() + private val platformBox = ComboBox(platforms) + private val surface = Surface() + private val error = JTextArea() + private var file: VirtualFile? = null + private var size: Dimension? = null + private var scale = 1.0 + private var rendering = false + private var renderPending = false + private var disposed = false + // what Auto picked + private var autoLabel: String? = null + + init { + background = editorBackground() + platformBox.toolTipText = "Platform to draw the preview with" + // wide enough for what Auto picked + platformBox.prototypeDisplayValue = PlatformOption(AUTO, "Auto (WinForms)") + platformBox.renderer = textListCellRenderer { + if (it?.id == AUTO && autoLabel != null && platformBox.item?.id == AUTO) "Auto ($autoLabel)" else it?.label + } + val options = listOf(PlatformOption(AUTO, "Auto")) + host.launcher.getPlatforms() + platforms.replaceAll(options) + platformBox.item = options.firstOrNull { it.id == state.platform } ?: options[0] + platformBox.addActionListener { + val id = platformBox.item?.id ?: AUTO + if (id != state.platform) { + state.platform = id + surface.status = "Drawing preview…" + render() + } + } + val toolbar = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(10), JBUI.scale(6))).apply { + isOpaque = false + add(platformBox) + } + + error.apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + isVisible = false + background = JBColor.namedColor("Banner.errorBackground", JBColor(0xFFF7F7, 0x3E2A2A)) + border = JBUI.Borders.compound( + JBUI.Borders.customLineTop(JBColor.namedColor("Banner.errorBorderColor", JBColor(0xF8D1D1, 0x5E3838))), + JBUI.Borders.empty(6, 10) + ) + } + + add(toolbar, BorderLayout.NORTH) + add(JBScrollPane(surface).apply { + border = JBUI.Borders.empty() + viewport.background = editorBackground() + }, BorderLayout.CENTER) + add(error, BorderLayout.SOUTH) + + EditorFactory.getInstance().eventMulticaster.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + if (FileDocumentManager.getInstance().getFile(event.document) == file) schedule() + } + }, this) + project.messageBus.connect(this).subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + val selected = event.newFile ?: return + if (EtoFiles.isPreviewable(selected) && selected != file) setFile(selected) + } + }) + // opened from the tool window rather than the action, so start with what's being edited + file = FileEditorManager.getInstance(project).selectedFiles.firstOrNull { EtoFiles.isPreviewable(it) } + host.onRedraw(this) { ApplicationManager.getApplication().invokeLater({ render() }, ModalityState.any()) } + + // moving to a screen with another scale needs a sharper or smaller image + addPropertyChangeListener("graphicsConfiguration") { updateScale() } + // nothing is drawn while hidden, so catch up when shown + addHierarchyListener { + if (it.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() != 0L && isShowing) { + updateScale() + render() + } + } + } + + fun setFile(file: VirtualFile) { + this.file = file + // a size picked for one file rarely suits the next + size = null + render() + } + + override fun dispose() { + disposed = true + } + + private fun updateScale() { + val newScale = graphicsConfiguration?.defaultTransform?.scaleX ?: 1.0 + if (newScale != scale) { + scale = newScale + render() + } + } + + private fun schedule() { + alarm.cancelAllRequests() + alarm.addRequest({ render() }, REFRESH_DELAY_MS) + } + + private fun render() { + val file = file ?: return + if (disposed || !isShowing) return + if (rendering) { + renderPending = true + return + } + rendering = true + renderPending = false + val text = ReadAction.compute { FileDocumentManager.getInstance().getDocument(file)?.text ?: "" } + val request = RenderRequest(file.path, text, size?.width, size?.height, scale) + host.render(request, state.platform).whenComplete { result, e -> + ApplicationManager.getApplication().invokeLater({ + rendering = false + if (disposed) return@invokeLater + // results for a file the user already moved away from would only flicker + if (file == this.file) + show(result ?: RenderResult(errorMessage = "The preview could not be drawn.", errorDetails = e?.toString())) + if (renderPending) render() + }, ModalityState.any()) + } + } + + private fun show(result: RenderResult) { + autoLabel = result.platform + platformBox.repaint() + if (result.errorMessage != null) { + // keep the last good preview up + error.text = result.errorMessage + error.toolTipText = result.errorDetails + error.isVisible = true + surface.status = null + } else { + error.isVisible = false + surface.status = null + val image = result.image?.let { ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(it))) } + surface.show(image, result.width ?: image?.width ?: 0, result.height ?: image?.height ?: 0, size != null) + } + revalidate() + repaint() + } + + private fun onResize(width: Int, height: Int) { + size = Dimension(width, height) + render() + } + + private fun onReset() { + size = null + render() + } + + /** The drawn form, centered, with its size above it and a handle to resize it by. */ + private inner class Surface : JPanel(null) { + private val top get() = JBUI.scale(40) + private val side get() = JBUI.scale(32) + private val gripSize get() = JBUI.scale(10) + private var image: BufferedImage? = null + private var frameWidth = 0 + private var frameHeight = 0 + private var sized = false + private var hover = false + private var drag: Pair? = null + private var sent = 0L + + var status: String? = "Drawing preview…" + set(value) { + field = value + repaint() + } + + init { + isOpaque = true + background = editorBackground() + val mouse = object : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + val over = image != null && frame().apply { grow(gripSize, gripSize) }.contains(e.point) + if (over != hover) { + hover = over + repaint() + } + cursor = when { + grip().contains(e.point) -> Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR) + sized && badge().contains(e.point) -> Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + else -> Cursor.getDefaultCursor() + } + toolTipText = when { + grip().contains(e.point) -> "Drag to resize" + sized && badge().contains(e.point) -> "Click to reset to auto size" + else -> null + } + } + + override fun mouseExited(e: MouseEvent) { + if (hover && drag == null) { + hover = false + repaint() + } + } + + override fun mousePressed(e: MouseEvent) { + if (image != null && grip().contains(e.point)) + drag = e.point to Dimension(frameWidth, frameHeight) + } + + override fun mouseDragged(e: MouseEvent) { + val (start, startSize) = drag ?: return + // centered, so it grows both ways and the edge under the mouse moves twice as far + frameWidth = maxOf(1, startSize.width + (e.x - start.x) * 2) + frameHeight = maxOf(1, startSize.height + e.y - start.y) + revalidate() + repaint() + val now = System.currentTimeMillis() + if (now - sent > 100) { + sent = now + onResize(frameWidth, frameHeight) + } + } + + override fun mouseReleased(e: MouseEvent) { + if (drag == null) return + drag = null + repaint() + onResize(frameWidth, frameHeight) + } + + override fun mouseClicked(e: MouseEvent) { + if (sized && badge().contains(e.point)) onReset() + } + } + addMouseListener(mouse) + addMouseMotionListener(mouse) + } + + fun show(image: BufferedImage?, width: Int, height: Int, sized: Boolean) { + this.image = image + this.sized = sized + // while dragging the frame follows the mouse, and an older result mustn't undo that + if (drag == null) { + frameWidth = width + frameHeight = height + } + revalidate() + repaint() + } + + private fun frame() = Rectangle(maxOf(side, (width - frameWidth) / 2), top, frameWidth, frameHeight) + + private fun grip(): Rectangle = frame().let { Rectangle(it.x + it.width - 1, it.y + it.height - 1, gripSize, gripSize) } + + private fun badge(): Rectangle { + val frame = frame() + val metrics = getFontMetrics(badgeFont()) + val w = metrics.stringWidth(sizeText()) + JBUI.scale(12) + val h = metrics.height + JBUI.scale(2) + return Rectangle(frame.x + (frame.width - w) / 2, frame.y - JBUI.scale(30), w, h) + } + + private fun sizeText() = "${frameWidth}x$frameHeight" + + private fun badgeFont() = JBUI.Fonts.smallFont() + + override fun getPreferredSize(): Dimension = + if (image == null) Dimension(0, 0) + else Dimension(frameWidth + side * 2 + gripSize, frameHeight + top + side) + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + GraphicsUtil.setupAntialiasing(g2) + status?.let { + g2.color = UIUtil.getContextHelpForeground() + g2.drawString(it, side, JBUI.scale(16) + g2.fontMetrics.ascent) + } + val image = image ?: return + val frame = frame() + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g2.drawImage(image, frame.x, frame.y, frame.width, frame.height, null) + + val focus = JBUI.CurrentTheme.Focus.focusColor() + if (hover || drag != null) { + val outset = JBUI.scale(4) + g2.color = focus + g2.stroke = BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, floatArrayOf(3f, 3f), 0f) + g2.drawRect(frame.x - outset, frame.y - outset, frame.width + outset * 2 - 1, frame.height + outset * 2 - 1) + } + + val badge = badge() + g2.color = JBColor.namedColor("Counter.background", JBColor(0xCCCCCC, 0x555555)) + g2.fillRoundRect(badge.x, badge.y, badge.width, badge.height, JBUI.scale(4), JBUI.scale(4)) + g2.color = JBColor.namedColor("Counter.foreground", JBColor.foreground()) + g2.font = badgeFont() + val metrics = g2.fontMetrics + g2.drawString(sizeText(), badge.x + (badge.width - metrics.stringWidth(sizeText())) / 2, badge.y + JBUI.scale(1) + metrics.ascent) + + val grip = grip() + g2.color = focus + g2.fill(Ellipse2D.Float(grip.x.toFloat(), grip.y.toFloat(), grip.width.toFloat(), grip.height.toFloat())) + } finally { + g2.dispose() + } + } + } +} + +private fun editorBackground() = EditorColorsManager.getInstance().globalScheme.defaultBackground diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt new file mode 100644 index 0000000..3f53c38 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt @@ -0,0 +1,139 @@ +package com.picoe.eto.rider.preview + +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.SimplePersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.StoragePathMacros +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.ContentFactory +import com.picoe.eto.rider.EtoFiles + +private const val TOOL_WINDOW_ID = "Eto Preview" + +/** Hidden until a designer file is opened, so it doesn't show up in projects that don't use Eto. */ +class PreviewToolWindowFactory : ToolWindowFactory, DumbAware { + override suspend fun isApplicableAsync(project: Project) = true + + override fun shouldBeAvailable(project: Project) = false + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val factory = ContentFactory.getInstance() + val panel = PreviewPanel(project) + toolWindow.contentManager.addContent(factory.createContent(panel, "Preview", false).apply { + isCloseable = false + setDisposer(panel) + }) + + val console = TextConsoleBuilderFactory.getInstance().createBuilder(project).apply { setViewer(true) }.console + toolWindow.contentManager.addContent(factory.createContent(console.component, "Log", false).apply { + isCloseable = false + setDisposer(console) + }) + project.service().attach(console) + } +} + +/** Makes the Eto Preview tool window available once a designer file is opened. */ +class PreviewAvailability(private val project: Project) : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + if (!EtoFiles.isPreviewable(file)) return + ApplicationManager.getApplication().invokeLater({ + ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID)?.isAvailable = true + }, project.disposed) + } +} + +/** Opens the preview beside the editor, for the file it was run on. */ +class OpenPreviewAction : AnAction(), DumbAware { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + val file = e.getData(CommonDataKeys.VIRTUAL_FILE) + e.presentation.isEnabledAndVisible = e.project != null && file != null && EtoFiles.isPreviewable(file) + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val file = e.getData(CommonDataKeys.VIRTUAL_FILE)?.takeIf { EtoFiles.isPreviewable(it) } ?: return + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) ?: return + toolWindow.isAvailable = true + toolWindow.show { + val content = toolWindow.contentManager.contents.firstOrNull { it.component is PreviewPanel } ?: return@show + toolWindow.contentManager.setSelectedContent(content) + (content.component as PreviewPanel).setFile(file) + } + } +} + +/** The platform picked in the preview's drop down, per project. */ +@Service(Service.Level.PROJECT) +@State(name = "EtoPreview", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) +class PreviewPlatformState : SimplePersistentStateComponent(Options()) { + class Options : BaseState() { + var platform by string(AUTO) + } + + /** A platform id, or [AUTO]. */ + var platform: String + get() = state.platform ?: AUTO + set(value) { + state.platform = value + } +} + +/** Output from the preview host, shown in the tool window's Log tab. */ +@Service(Service.Level.PROJECT) +class PreviewLog : Disposable { + private val buffer = StringBuilder() + private var console: ConsoleView? = null + + fun appendLine(text: String) { + logger().info(text) + synchronized(this) { + val console = console + if (console != null) { + console.print(text + "\n", ConsoleViewContentType.NORMAL_OUTPUT) + return + } + // kept until the tool window opens, trimmed so a chatty host can't grow it forever + buffer.append(text).append('\n') + if (buffer.length > 100_000) buffer.delete(0, buffer.length - 100_000) + } + } + + fun attach(console: ConsoleView) { + synchronized(this) { + this.console = console + console.print(buffer.toString(), ConsoleViewContentType.NORMAL_OUTPUT) + buffer.setLength(0) + } + Disposer.register(console) { + synchronized(this) { + if (this.console === console) this.console = null + } + } + } + + override fun dispose() {} +} diff --git a/src/Eto.DevExtension.Rider/src/main/resources/META-INF/plugin.xml b/src/Eto.DevExtension.Rider/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..fc21f57 --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,57 @@ + + com.picoe.eto.designer + Eto.Forms Designer + Picoe Software Solutions + + Eto.Forms + .xeto and .jeto designer files. +
    +
  • A live preview of .xeto, .jeto and .eto.cs files, drawn with the + Eto.Forms platform of your choice. Run Open Eto Preview from the editor's context menu.
  • +
  • Completion of control names, properties, events and values, including your own controls once the project is built
  • +
  • Hover documentation from the Eto.Forms xml docs
  • +
  • Syntax highlighting for both file types
  • +
+ Needs the .NET 8 runtime or newer. + ]]>
+ + com.intellij.modules.platform + com.intellij.modules.lsp + com.intellij.modules.rider + org.jetbrains.plugins.textmate + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/Eto.DevExtension.Rider/src/main/resources/META-INF/pluginIcon.svg b/src/Eto.DevExtension.Rider/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000..f933b2a --- /dev/null +++ b/src/Eto.DevExtension.Rider/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,3 @@ + + +