Skip to content

Commit

Permalink
Its own platform implementation of ANSI level acquisition
Browse files Browse the repository at this point in the history
Now `mordant` is not used as a layer for this.
Also, the dependency on `jline3` has been changed to get only the necessary artifacts.
  • Loading branch information
EpicDima committed Jun 15, 2024
1 parent c2c4d84 commit dc97a19
Show file tree
Hide file tree
Showing 14 changed files with 225 additions and 22 deletions.
10 changes: 8 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand All @@ -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" ]
6 changes: 6 additions & 0 deletions mosaic-runtime/api/mosaic-runtime.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions mosaic-runtime/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ kotlin {
dependencies {
api libs.compose.runtime
api libs.kotlinx.coroutines.core
implementation libs.mordant
implementation libs.codepoints
}
}
Expand All @@ -35,7 +34,7 @@ kotlin {
jvmMain {
dependsOn(concurrentMain)
dependencies {
implementation libs.jline3
implementation libs.bundles.jline3
}
}
nonJvmMain {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
148 changes: 138 additions & 10 deletions mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/ansi.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ 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)
}

/**
* Unpacks the first Int value in [packInts] from its returned ULong.
*/
@PublishedApi
internal inline fun unpackInt1(value: Long): Int {
return value.shr(32).toInt()
}

/**
* Unpacks the second Int value in [packInts] from its returned ULong.
*/
@PublishedApi
internal inline fun unpackInt2(value: Long): Int {
return value.and(0xFFFFFFFF).toInt()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

0 comments on commit dc97a19

Please sign in to comment.