From dc97a191512c6889a0071f96f8eb112484eb45b6 Mon Sep 17 00:00:00 2001 From: EpicDima Date: Sat, 15 Jun 2024 19:29:40 +0300 Subject: [PATCH] Its own platform implementation of ANSI level acquisition Now `mordant` is not used as a layer for this. Also, the dependency on `jline3` has been changed to get only the necessary artifacts. --- gradle/libs.versions.toml | 10 +- mosaic-runtime/api/mosaic-runtime.api | 6 + mosaic-runtime/build.gradle | 3 +- .../kotlin/com/jakewharton/mosaic/platform.kt | 11 ++ .../kotlin/com/jakewharton/mosaic/ansi.kt | 148 ++++++++++++++++-- .../kotlin/com/jakewharton/mosaic/mosaic.kt | 7 +- .../kotlin/com/jakewharton/mosaic/platform.kt | 6 + .../mosaic/ui/unit/InlineClassUtils.kt | 3 + .../kotlin/com/jakewharton/mosaic/platform.kt | 6 + .../kotlin/com/jakewharton/mosaic/platform.kt | 21 ++- .../kotlin/com/jakewharton/mosaic/platform.kt | 11 ++ .../kotlin/com/jakewharton/mosaic/platform.kt | 11 ++ samples/robot/build.gradle | 2 +- samples/rrtop/build.gradle | 2 +- 14 files changed, 225 insertions(+), 22 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ff00adb0..996c8c52a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] kotlin = "2.0.0" +jline3 = "3.26.1" [libraries] kotlin-plugin-core = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -19,9 +20,14 @@ spotless-gradlePlugin = "com.diffplug.spotless:spotless-plugin-gradle:6.25.0" ktlint-core = "com.pinterest.ktlint:ktlint-cli:1.3.0" ktlint-composeRules = "io.nlopez.compose.rules:ktlint:0.4.4" -jline3 = "org.jline:jline:3.26.1" -mordant = "com.github.ajalt.mordant:mordant:2.6.0" +jline3-terminal = { module = "org.jline:jline-terminal", version.ref = "jline3" } +jline3-native = { module = "org.jline:jline-terminal-jna", version.ref = "jline3" } +jline3-terminal-jna = { module = "org.jline:jline-terminal-jna", version.ref = "jline3" } + codepoints = "de.cketti.unicode:kotlin-codepoints:0.8.0" junit4 = "junit:junit:4.13.2" assertk = "com.willowtreeapps.assertk:assertk:0.28.1" + +[bundles] +jline3 = [ "jline3-terminal", "jline3-native", "jline3-terminal-jna" ] diff --git a/mosaic-runtime/api/mosaic-runtime.api b/mosaic-runtime/api/mosaic-runtime.api index d7df101d7..8605eda79 100644 --- a/mosaic-runtime/api/mosaic-runtime.api +++ b/mosaic-runtime/api/mosaic-runtime.api @@ -608,6 +608,12 @@ public final class com/jakewharton/mosaic/ui/unit/ConstraintsKt { public static synthetic fun offset-cpom3Pk$default (JIIILjava/lang/Object;)J } +public final class com/jakewharton/mosaic/ui/unit/InlineClassUtilsKt { + public static final fun packInts (II)J + public static final fun unpackInt1 (J)I + public static final fun unpackInt2 (J)I +} + public final class com/jakewharton/mosaic/ui/unit/IntOffset { public static final field Companion Lcom/jakewharton/mosaic/ui/unit/IntOffset$Companion; public static final synthetic fun box-impl (J)Lcom/jakewharton/mosaic/ui/unit/IntOffset; diff --git a/mosaic-runtime/build.gradle b/mosaic-runtime/build.gradle index d2e10847d..eee5af7ae 100644 --- a/mosaic-runtime/build.gradle +++ b/mosaic-runtime/build.gradle @@ -17,7 +17,6 @@ kotlin { dependencies { api libs.compose.runtime api libs.kotlinx.coroutines.core - implementation libs.mordant implementation libs.codepoints } } @@ -35,7 +34,7 @@ kotlin { jvmMain { dependsOn(concurrentMain) dependencies { - implementation libs.jline3 + implementation libs.bundles.jline3 } } nonJvmMain { diff --git a/mosaic-runtime/src/appleMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/appleMain/kotlin/com/jakewharton/mosaic/platform.kt index ee4f1ab94..1e2ee9394 100644 --- a/mosaic-runtime/src/appleMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/appleMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -4,9 +4,13 @@ import com.jakewharton.mosaic.ui.unit.IntSize import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped +import kotlinx.cinterop.toKStringFromUtf8 import platform.posix.STDIN_FILENO +import platform.posix.STDOUT_FILENO import platform.posix.TIOCGWINSZ +import platform.posix.getenv import platform.posix.ioctl +import platform.posix.isatty import platform.posix.winsize @OptIn(ExperimentalForeignApi::class) @@ -18,3 +22,10 @@ internal actual fun getPlatformTerminalSize(): IntSize = memScoped { IntSize(width = size.ws_col.toInt(), height = size.ws_row.toInt()) } } + +@OptIn(ExperimentalForeignApi::class) +internal actual fun getEnv(key: String): String? = getenv(key)?.toKStringFromUtf8() + +internal actual fun runningInIdeaJavaAgent(): Boolean = false + +internal actual fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ansi.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ansi.kt index 75f8dfb9a..0ceece9c2 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ansi.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ansi.kt @@ -1,6 +1,5 @@ package com.jakewharton.mosaic -import com.github.ajalt.mordant.rendering.AnsiLevel as MordantAnsiLevel import com.jakewharton.mosaic.ui.AnsiLevel import com.jakewharton.mosaic.ui.Color import kotlin.math.roundToInt @@ -29,15 +28,6 @@ internal const val ansiBgColorOffset = 10 internal const val ansiSelectorColor256 = 5 internal const val ansiSelectorColorRgb = 2 -internal fun MordantAnsiLevel.toMosaicAnsiLevel(): AnsiLevel { - return when (this) { - MordantAnsiLevel.NONE -> AnsiLevel.NONE - MordantAnsiLevel.ANSI16 -> AnsiLevel.ANSI16 - MordantAnsiLevel.ANSI256 -> AnsiLevel.ANSI256 - MordantAnsiLevel.TRUECOLOR -> AnsiLevel.TRUECOLOR - } -} - // simpler version without full conversion to HSV // https://github.com/ajalt/colormath/blob/4a0cc9796c743cb4965407204ee63b40aaf22fca/colormath/src/commonMain/kotlin/com/github/ajalt/colormath/model/RGB.kt#L301 internal fun Color.toAnsi16Code(): Int { @@ -72,3 +62,141 @@ internal fun Color.toAnsi256Code(): Int { (blueFloat * 5).roundToInt() } } + +// region ansi level detection +// https://github.com/ajalt/mordant/blob/adad637a133a5221823fba9068b2f7ad8965d115/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt +internal fun getAnsiLevel(): AnsiLevel { + return ansiLevel(isIntellijRunActionConsole() || stdoutInteractive()) +} + +private fun ansiLevel(interactive: Boolean): AnsiLevel { + forcedColor()?.let { return it } + + // Terminals embedded in some IDEs support color even though stdout isn't interactive. Check + // those terminals before checking stdout. + if (isIntellijRunActionConsole() || isVsCodeTerminal()) return AnsiLevel.TRUECOLOR + + // If output isn't interactive, never output colors, since we might be redirected to a file etc. + if (!interactive) return AnsiLevel.NONE + + // Otherwise check the large variety of environment variables set by various terminal + // emulators to detect color support + + if (isWindowsTerminal() || isDomTerm()) return AnsiLevel.TRUECOLOR + + if (isJediTerm()) return AnsiLevel.TRUECOLOR + + when (getColorTerm()) { + "24bit", "24bits", "truecolor" -> return AnsiLevel.TRUECOLOR + } + + if (isCI()) { + return if (ciSupportsColor()) AnsiLevel.ANSI256 else AnsiLevel.NONE + } + + when (getTermProgram()) { + "hyper" -> return AnsiLevel.TRUECOLOR + "apple_terminal" -> return AnsiLevel.ANSI256 + "iterm.app" -> return if (iTermVersionSupportsTruecolor()) AnsiLevel.TRUECOLOR else AnsiLevel.ANSI256 + "wezterm" -> return AnsiLevel.TRUECOLOR + "mintty" -> return AnsiLevel.TRUECOLOR + } + + val (term, level) = getTerm()?.split("-") + ?.let { it.firstOrNull() to it.lastOrNull() } + ?: (null to null) + + when (level) { + "256", "256color", "256colors" -> return AnsiLevel.ANSI256 + "24bit", "24bits", "direct", "truecolor" -> return AnsiLevel.TRUECOLOR + } + + // If there's no explicit level (like "xterm") or the level is ansi16 (like "rxvt-16color"), + // guess the level based on the terminal. + + if (term == "xterm") { + // Xterm sets an envvar with a version string that's "normally an identifier for the X + // Window libraries used to build xterm, followed by xterm's patch number in + // parentheses" https://linux.die.net/man/1/xterm + // + // 331 added truecolor support, 122 added 256 color support + // https://invisible-island.net/xterm/xterm.log.html + val xtermVersion = getEnv("XTERM_VERSION") ?: return AnsiLevel.ANSI16 + val m = Regex("""\((\d+)\)""").find(xtermVersion) ?: return AnsiLevel.ANSI16 + val v = m.groupValues[1].toInt() + if (v >= 331) return AnsiLevel.TRUECOLOR + if (v >= 122) return AnsiLevel.ANSI256 + return AnsiLevel.ANSI16 + } + + return when (term) { + // New versions of Windows 10 cmd.exe supports truecolor, and most other terminal emulators + // like ConEmu and mintty support truecolor, although they might downsample it. + "cygwin" -> AnsiLevel.TRUECOLOR + "vt100", "vt220", "screen", "tmux", "color", "linux", "ansi", "rxvt", "konsole" -> AnsiLevel.ANSI16 + else -> AnsiLevel.NONE + } +} + +private fun getTerm(): String? = getEnv("TERM")?.lowercase() + +// https://github.com/termstandard/colors/ +private fun getColorTerm(): String? = getEnv("COLORTERM")?.lowercase() + +private fun forcedColor(): AnsiLevel? = when { + getTerm() == "dumb" -> AnsiLevel.NONE + // https://no-color.org/ + getEnv("NO_COLOR") != null -> AnsiLevel.NONE + // A lot of npm packages support the FORCE_COLOR envvar, although they all look for + // different values. We try to support them all. + else -> when (getEnv("FORCE_COLOR")?.lowercase()) { + "0", "false", "none" -> AnsiLevel.NONE + "1", "", "true", "16color" -> AnsiLevel.ANSI16 + "2", "256color" -> AnsiLevel.ANSI256 + "3", "truecolor" -> AnsiLevel.TRUECOLOR + else -> null + } +} + +private fun getTermProgram(): String? = getEnv("TERM_PROGRAM")?.lowercase() + +// https://github.com/Microsoft/vscode/pull/30346 +private fun isVsCodeTerminal(): Boolean = getTermProgram() == "vscode" + +// https://github.com/microsoft/terminal/issues/1040#issuecomment-496691842 +private fun isWindowsTerminal(): Boolean = !getEnv("WT_SESSION").isNullOrEmpty() + +// https://domterm.org/Detecting-domterm-terminal.html +private fun isDomTerm(): Boolean = !getEnv("DOMTERM").isNullOrEmpty() + +// https://github.com/JetBrains/intellij-community/blob/master/plugins/terminal/src/org/jetbrains/plugins/terminal/LocalTerminalDirectRunner.java#L141 +private fun isJediTerm(): Boolean = getEnv("TERMINAL_EMULATOR") == "JetBrains-JediTerm" + +private fun iTermVersionSupportsTruecolor(): Boolean { + val ver = getEnv("TERM_PROGRAM_VERSION")?.split(".")?.firstOrNull()?.toIntOrNull() + return ver != null && ver >= 3 +} + +private fun isCI(): Boolean { + return getEnv("CI") != null +} + +private fun ciSupportsColor(): Boolean { + return listOf( + "APPVEYOR", + "BUILDKITE", + "CIRCLECI", + "DRONE", + "GITHUB_ACTIONS", + "GITLAB_CI", + "TRAVIS", + ).any { getEnv(it) != null } +} + +private fun isIntellijRunActionConsole(): Boolean { + // For some reason, IntelliJ's terminal behaves differently when running from an IDE run action vs running from + // their terminal tab. In the latter case, the JediTerm envvar is set, in the former it's missing. + return !isJediTerm() && runningInIdeaJavaAgent() +} + +// endregion diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt index 39feebb2b..dfd1ad635 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Recomposer import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.Snapshot -import com.github.ajalt.mordant.terminal.Terminal as MordantTerminal import com.jakewharton.mosaic.layout.MosaicNode import com.jakewharton.mosaic.ui.BoxMeasurePolicy import kotlin.time.ExperimentalTime @@ -55,13 +54,11 @@ public interface MosaicScope : CoroutineScope { } public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = coroutineScope { - val terminal = MordantTerminal() - val rendering = if (debugOutput) { @OptIn(ExperimentalTime::class) // Not used in production. - DebugRendering(ansiLevel = terminal.info.ansiLevel.toMosaicAnsiLevel()) + DebugRendering(ansiLevel = getAnsiLevel()) } else { - AnsiRendering(ansiLevel = terminal.info.ansiLevel.toMosaicAnsiLevel()) + AnsiRendering(ansiLevel = getAnsiLevel()) } var hasFrameWaiters = false diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/platform.kt index c89d43085..63d87d73a 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -5,3 +5,9 @@ import com.jakewharton.mosaic.ui.unit.IntSize internal expect fun platformDisplay(chars: CharSequence) internal expect fun getPlatformTerminalSize(): IntSize + +internal expect fun getEnv(key: String): String? + +internal expect fun runningInIdeaJavaAgent(): Boolean + +internal expect fun stdoutInteractive(): Boolean diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ui/unit/InlineClassUtils.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ui/unit/InlineClassUtils.kt index 0869e23fe..1a6a419ba 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ui/unit/InlineClassUtils.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ui/unit/InlineClassUtils.kt @@ -5,6 +5,7 @@ package com.jakewharton.mosaic.ui.unit /** * Packs two Int values into one Long value for use in inline classes. */ +@PublishedApi internal inline fun packInts(val1: Int, val2: Int): Long { return val1.toLong().shl(32) or (val2.toLong() and 0xFFFFFFFF) } @@ -12,6 +13,7 @@ internal inline fun packInts(val1: Int, val2: Int): Long { /** * Unpacks the first Int value in [packInts] from its returned ULong. */ +@PublishedApi internal inline fun unpackInt1(value: Long): Int { return value.shr(32).toInt() } @@ -19,6 +21,7 @@ internal inline fun unpackInt1(value: Long): Int { /** * Unpacks the second Int value in [packInts] from its returned ULong. */ +@PublishedApi internal inline fun unpackInt2(value: Long): Int { return value.and(0xFFFFFFFF).toInt() } diff --git a/mosaic-runtime/src/jsMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/jsMain/kotlin/com/jakewharton/mosaic/platform.kt index 83dda24de..139eb21d9 100644 --- a/mosaic-runtime/src/jsMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/jsMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -12,3 +12,9 @@ internal actual fun getPlatformTerminalSize(): IntSize { IntSize(width = size[0] as Int, height = size[1] as Int) } } + +internal actual fun getEnv(key: String): String? = process.env[key] as? String + +internal actual fun runningInIdeaJavaAgent(): Boolean = false + +internal actual fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean diff --git a/mosaic-runtime/src/jvmMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/jvmMain/kotlin/com/jakewharton/mosaic/platform.kt index 76e892b56..b33031b63 100644 --- a/mosaic-runtime/src/jvmMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/jvmMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -1,11 +1,15 @@ package com.jakewharton.mosaic import com.jakewharton.mosaic.ui.unit.IntSize +import java.lang.management.ManagementFactory import java.nio.CharBuffer import java.nio.charset.StandardCharsets.UTF_8 +import org.jline.nativ.CLibrary import org.jline.terminal.TerminalBuilder -/* invoking enterRawMode - it is a hack to use key events in samples */ +private const val STDOUT_FILENO = 1 + +/* todo: invoking enterRawMode - it is a hack to use key events in samples */ private val terminal = TerminalBuilder.terminal().also { it.enterRawMode() } private val out = terminal.output() private val encoder = UTF_8.newEncoder() @@ -27,3 +31,18 @@ internal actual fun platformDisplay(chars: CharSequence) { internal actual fun getPlatformTerminalSize(): IntSize { return IntSize(terminal.width, terminal.height) } + +internal actual fun getEnv(key: String): String? = System.getenv(key) + +// Depending on how IntelliJ is configured, it might use its own Java agent +internal actual fun runningInIdeaJavaAgent(): Boolean { + return try { + val bean = ManagementFactory.getRuntimeMXBean() + val jvmArgs = bean.inputArguments + jvmArgs.any { it.startsWith("-javaagent") && "idea_rt.jar" in it } + } catch (e: SecurityException) { + false + } +} + +internal actual fun stdoutInteractive(): Boolean = CLibrary.isatty(STDOUT_FILENO) != 0 diff --git a/mosaic-runtime/src/linuxMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/linuxMain/kotlin/com/jakewharton/mosaic/platform.kt index ff328bc24..d51812020 100644 --- a/mosaic-runtime/src/linuxMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/linuxMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -4,9 +4,13 @@ import com.jakewharton.mosaic.ui.unit.IntSize import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped +import kotlinx.cinterop.toKStringFromUtf8 import platform.linux.ioctl import platform.posix.STDIN_FILENO +import platform.posix.STDOUT_FILENO import platform.posix.TIOCGWINSZ +import platform.posix.getenv +import platform.posix.isatty import platform.posix.winsize @OptIn(ExperimentalForeignApi::class) @@ -18,3 +22,10 @@ internal actual fun getPlatformTerminalSize(): IntSize = memScoped { IntSize(width = size.ws_col.toInt(), height = size.ws_row.toInt()) } } + +@OptIn(ExperimentalForeignApi::class) +internal actual fun getEnv(key: String): String? = getenv(key)?.toKStringFromUtf8() + +internal actual fun runningInIdeaJavaAgent(): Boolean = false + +internal actual fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 diff --git a/mosaic-runtime/src/mingwMain/kotlin/com/jakewharton/mosaic/platform.kt b/mosaic-runtime/src/mingwMain/kotlin/com/jakewharton/mosaic/platform.kt index dd1b9cb40..4d0fa0e03 100644 --- a/mosaic-runtime/src/mingwMain/kotlin/com/jakewharton/mosaic/platform.kt +++ b/mosaic-runtime/src/mingwMain/kotlin/com/jakewharton/mosaic/platform.kt @@ -5,6 +5,10 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKStringFromUtf8 +import platform.posix.STDOUT_FILENO +import platform.posix.getenv +import platform.posix.isatty import platform.windows.CONSOLE_SCREEN_BUFFER_INFO import platform.windows.GetConsoleScreenBufferInfo import platform.windows.GetStdHandle @@ -23,3 +27,10 @@ internal actual fun getPlatformTerminalSize(): IntSize = memScoped { } csbi.srWindow.run { IntSize(width = Right - Left + 1, height = Bottom - Top + 1) } } + +@OptIn(ExperimentalForeignApi::class) +internal actual fun getEnv(key: String): String? = getenv(key)?.toKStringFromUtf8() + +internal actual fun runningInIdeaJavaAgent(): Boolean = false + +internal actual fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 diff --git a/samples/robot/build.gradle b/samples/robot/build.gradle index 37b13bbb4..4b0fef367 100644 --- a/samples/robot/build.gradle +++ b/samples/robot/build.gradle @@ -8,5 +8,5 @@ application { dependencies { implementation projects.mosaicRuntime - implementation libs.jline3 + implementation libs.bundles.jline3 } diff --git a/samples/rrtop/build.gradle b/samples/rrtop/build.gradle index c773399fe..ac42a5a3e 100644 --- a/samples/rrtop/build.gradle +++ b/samples/rrtop/build.gradle @@ -9,5 +9,5 @@ application { dependencies { implementation projects.mosaicRuntime implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.6.0' - implementation libs.jline3 + implementation libs.bundles.jline3 }