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 0000000..5097068 Binary files /dev/null and b/src/Eto.DevExtension.Rider/gradle/wrapper/gradle-wrapper.jar differ 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 @@ + + +