diff --git a/README.md b/README.md index a1ea0f94..6195530e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,43 @@ emulation. * URLs * Compilation errors +## Host testing with Robolectric + +The `termlib-host` artifact lets you run Robolectric tests without requiring a +native `.so` on the host. It ships a WASM-based backend and a Robolectric shadow +that transparently replaces `TerminalNative` so `TerminalEmulatorFactory.create()` +works as normal in tests. + +### Setup + +Add to your module's `build.gradle.kts`: + +```kotlin +dependencies { + testImplementation("org.connectbot:termlib:") + testImplementation("org.connectbot:termlib-host:") + testImplementation("org.robolectric:robolectric:") +} +``` + +Then activate the shadow either per test class: + +```kotlin +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowTerminalNative::class]) +class MyTerminalTest { ... } +``` + +or globally for all tests in `src/test/resources/robolectric.properties`: + +``` +sdk=34 +shadows=org.connectbot.terminal.wasm.ShadowTerminalNative +``` + +Once the shadow is active, use `TerminalEmulatorFactory.create()` as you normally +would — no `java.library.path` configuration or native build step required. + ## Used libraries * libvterm by Paul Evans ; MIT licensed diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 00000000..d03dc6a6 --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,39 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.jmh) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +val libNativeHostJniDir = project(":lib-native").layout.buildDirectory.dir("host-jni") + +tasks.named("jmh") { + dependsOn(project(":lib-native").tasks.named("cmakeBuildHost")) +} + +jmh { + warmupIterations = 3 + iterations = 5 + fork = 1 + benchmarkMode = listOf("thrpt", "avgt") + timeUnit = "ms" + resultFormat = "JSON" + jvmArgs = listOf("-Djava.library.path=${libNativeHostJniDir.get().asFile.absolutePath}") +} + +dependencies { + jmh(project(":lib-native")) + jmh(project(":lib-wasm")) +} diff --git a/benchmark/src/jmh/kotlin/org/connectbot/terminal/benchmark/TerminalBackendBenchmark.kt b/benchmark/src/jmh/kotlin/org/connectbot/terminal/benchmark/TerminalBackendBenchmark.kt new file mode 100644 index 00000000..2954cf71 --- /dev/null +++ b/benchmark/src/jmh/kotlin/org/connectbot/terminal/benchmark/TerminalBackendBenchmark.kt @@ -0,0 +1,207 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal.benchmark + +import org.connectbot.terminal.CellRun +import org.connectbot.terminal.CursorPosition +import org.connectbot.terminal.ScreenCell +import org.connectbot.terminal.TermRect +import org.connectbot.terminal.TerminalBackend +import org.connectbot.terminal.TerminalCallbacks +import org.connectbot.terminal.TerminalNative +import org.connectbot.terminal.TerminalProperty +import org.connectbot.terminal.wasm.TerminalWasm +import org.connectbot.terminal.wasm.WasmCallbacks +import org.connectbot.terminal.wasm.WasmScreenCell +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.TearDown +import org.openjdk.jmh.annotations.Warmup +import java.util.concurrent.TimeUnit + +private const val ROWS = 24 +private const val COLS = 80 + +/** Generates [screenfulls] pages of mixed ASCII + ANSI SGR escape sequences. */ +private fun buildVtInput(screenfulls: Int): ByteArray { + val sb = StringBuilder() + val proto = "The quick brown fox jumps over the lazy dog. " + val line = proto.repeat((COLS / proto.length) + 1).substring(0, COLS - 2) + repeat(screenfulls * ROWS) { row -> + if (row % 2 == 0) sb.append("\u001B[1m") else sb.append("\u001B[0m") + sb.append(line) + sb.append("\r\n") + } + return sb.toString().toByteArray(Charsets.UTF_8) +} + +private val noopNativeCallbacks = object : TerminalCallbacks { + override fun damage(startRow: Int, endRow: Int, startCol: Int, endCol: Int) = 0 + override fun damageBatch(rects: IntArray, count: Int) = Unit + override fun moverect(dest: TermRect, src: TermRect) = 0 + override fun moveCursor(pos: CursorPosition, oldPos: CursorPosition, visible: Boolean) = 0 + override fun setTermProp(prop: Int, value: TerminalProperty) = 0 + override fun bell() = 0 + override fun pushScrollbackLine(cols: Int, cells: Array, softWrapped: Boolean) = 0 + override fun popScrollbackLine(cols: Int, cells: Array) = 0 + override fun onKeyboardInput(data: ByteArray) = 0 + override fun onOscSequence(command: Int, payload: String, cursorRow: Int, cursorCol: Int) = 0 +} + +private val noopWasmCallbacks = object : WasmCallbacks { + override fun damage(startRow: Int, endRow: Int, startCol: Int, endCol: Int) = 0 + override fun moverect( + dstStartRow: Int, + dstEndRow: Int, + dstStartCol: Int, + dstEndCol: Int, + srcStartRow: Int, + srcEndRow: Int, + srcStartCol: Int, + srcEndCol: Int, + ) = 0 + override fun moveCursor(row: Int, col: Int, oldRow: Int, oldCol: Int, visible: Boolean) = 0 + override fun setTermProp(prop: Int, type: Int, iVal: Int, str: String?) = 0 + override fun bell() = 0 + override fun pushScrollbackLine(cells: List, softWrapped: Boolean) = 0 + override fun popScrollbackLine(cols: Int): List? = null + override fun onKeyboardOutput(data: ByteArray) = Unit + override fun onOscSequence(command: Int, payload: String, cursorRow: Int, cursorCol: Int) = 0 +} + +// --------------------------------------------------------------------------- +// Shared backend state — one instance per trial, identified by @Param +// --------------------------------------------------------------------------- + +@State(Scope.Benchmark) +open class BackendState { + @Param("native", "wasm") + lateinit var backend: String + + lateinit var term: TerminalBackend + + /** Pre-built input that fills exactly one screenful of text. */ + val oneScreenInput: ByteArray = buildVtInput(1) + + /** Large burst: 100 screenfuls. */ + val largeInput: ByteArray = buildVtInput(100) + + val run = CellRun() + + @Setup(Level.Trial) + fun setUp() { + term = when (backend) { + "native" -> TerminalNative(noopNativeCallbacks) + "wasm" -> TerminalWasm(ROWS, COLS, noopWasmCallbacks) + else -> error("Unknown backend: $backend") + } + // Pre-populate the screen so cell-scan benchmarks have real content. + term.writeInput(oneScreenInput) + } + + @TearDown(Level.Trial) + fun tearDown() = term.close() +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput, Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(1) +open class WriteInputBenchmark { + + @Benchmark + fun writeOneScreen(state: BackendState): Int = state.term.writeInput(state.oneScreenInput) + + @Benchmark + fun writeLargeBurst(state: BackendState): Int = state.term.writeInput(state.largeInput) +} + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput, Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(1) +open class CellScanBenchmark { + + /** Scans every cell run via getCellRun (one Wasm call per run). */ + @Benchmark + fun scanAllCells(state: BackendState): Int { + val run = state.run + var total = 0 + for (row in 0 until ROWS) { + var col = 0 + while (col < COLS) { + val n = state.term.getCellRun(row, col, run) + if (n <= 0) break + total += n + col += n + } + } + return total + } + + /** Scans every cell run via scanRow (one Wasm call per row). */ + @Benchmark + fun scanAllCellsRowBatch(state: BackendState): Int { + val run = state.run + var total = 0 + for (row in 0 until ROWS) { + state.term.scanRow(row, COLS, run) { total += it.runLength } + } + return total + } + + /** Scans every cell run via scanAllRows (one Wasm call for the whole screen). */ + @Benchmark + fun scanAllCellsScreenDump(state: BackendState): Int { + val run = state.run + var total = 0 + state.term.scanAllRows(ROWS, COLS, run, block = { total += it.runLength }) + return total + } +} + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput, Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 2) +@Fork(1) +open class ResizeBenchmark { + + @Benchmark + fun resizeToggle(state: BackendState): Int { + state.term.resize(48, 132) + return state.term.resize(ROWS, COLS) + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 00000000..b9ee26aa --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation("com.vanniktech:gradle-maven-publish-plugin:0.36.0") +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..7fbbd448 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "build-logic" diff --git a/build-logic/src/main/kotlin/termlib-publish.gradle.kts b/build-logic/src/main/kotlin/termlib-publish.gradle.kts new file mode 100644 index 00000000..a078b2c1 --- /dev/null +++ b/build-logic/src/main/kotlin/termlib-publish.gradle.kts @@ -0,0 +1,36 @@ +import com.vanniktech.maven.publish.DeploymentValidation +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +private val gitHubUrl = "https://github.com/connectbot/termlib" + +plugins { + id("com.vanniktech.maven.publish") +} + +extensions.configure { + publishToMavenCentral(automaticRelease = true, validateDeployment = DeploymentValidation.PUBLISHED) + signAllPublications() + + pom { + url.set(gitHubUrl) + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("kruton") + name.set("Kenny Root") + url.set("https://github.com/kruton/") + } + } + scm { + connection.set("scm:git:$gitHubUrl.git") + developerConnection.set("$gitHubUrl.git") + url.set(gitHubUrl) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0aa3ee31..dd5b6957 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,9 +4,13 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.spotless) alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.release) + alias(libs.plugins.publish) apply false + alias(libs.plugins.wasm2class) apply false + alias(libs.plugins.jmh) apply false } spotless { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95133923..87a0380c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,13 @@ [versions] androidGradlePlugin = "9.2.0" kotlin = "2.3.21" +chicory = "1.5.1" spotless = "8.4.0" publish = "0.36.0" release = "3.1.0" metalava = "0.5.0" dokka = "2.2.0" +wasm2class = "0.5.0" composeBom = "2026.04.01" lifecycleViewModel = "2.10.0" @@ -21,6 +23,7 @@ mockk = "1.14.9" junitClassic = "4.13.2" robolectric = "4.16.1" +jmh = "0.7.3" ui = "1.11.0" [libraries] @@ -46,13 +49,21 @@ androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtim androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } +chicory-runtime = { module = "com.dylibso.chicory:runtime", version.ref = "chicory" } +chicory-wasm = { module = "com.dylibso.chicory:wasm", version.ref = "chicory" } +chicory-wasi = { module = "com.dylibso.chicory:wasi", version.ref = "chicory" } +chicory-buildTimeCompiler = { module = "com.dylibso.chicory:build-time-compiler", version.ref = "chicory" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } publish = { id = "com.vanniktech.maven.publish", version.ref = "publish" } release = { id = "net.researchgate.release", version.ref = "release" } metalava = { id = "me.tylerbwong.gradle.metalava", version.ref = "metalava" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +wasm2class = { id = "at.released.wasm2class.plugin", version.ref = "wasm2class" } +jmh = { id = "me.champeau.jmh", version.ref = "jmh" } diff --git a/lib-intf/build.gradle.kts b/lib-intf/build.gradle.kts new file mode 100644 index 00000000..a0a01785 --- /dev/null +++ b/lib-intf/build.gradle.kts @@ -0,0 +1,28 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) + id("termlib-publish") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +mavenPublishing { + coordinates(groupId = "org.connectbot", artifactId = "termlib-intf") + + pom { + name.set("termlib-intf") + description.set("ConnectBot terminal library interfaces") + inceptionYear.set("2026") + } +} diff --git a/lib/src/main/java/org/connectbot/terminal/CellRun.kt b/lib-intf/src/main/kotlin/org/connectbot/terminal/CellRun.kt similarity index 98% rename from lib/src/main/java/org/connectbot/terminal/CellRun.kt rename to lib-intf/src/main/kotlin/org/connectbot/terminal/CellRun.kt index f35645a2..16a0034d 100644 --- a/lib/src/main/java/org/connectbot/terminal/CellRun.kt +++ b/lib-intf/src/main/kotlin/org/connectbot/terminal/CellRun.kt @@ -22,7 +22,7 @@ package org.connectbot.terminal * * This class is reusable - call reset() before each getCellRun() call. */ -internal class CellRun { +class CellRun { // Foreground color (RGB) var fgRed: Int = 0 var fgGreen: Int = 0 diff --git a/lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalBackend.kt b/lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalBackend.kt new file mode 100644 index 00000000..0cf858b8 --- /dev/null +++ b/lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalBackend.kt @@ -0,0 +1,102 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal + +/** + * Common interface for terminal emulator backends. + */ +interface TerminalBackend : AutoCloseable { + fun writeInput( + data: ByteArray, + offset: Int = 0, + length: Int = data.size - offset, + ): Int + + fun resize( + rows: Int, + cols: Int, + ): Int + + fun dispatchKey( + modifiers: Int, + key: Int, + ): Boolean + + fun dispatchCharacter( + modifiers: Int, + codepoint: Int, + ): Boolean + + fun getCellRun( + row: Int, + col: Int, + run: CellRun, + ): Int + + /** + * Iterates all cell runs in [row], invoking [block] for each run. + * Default implementation loops via [getCellRun]; backends may override + * with a single cross-boundary call that fetches the entire row at once. + */ + fun scanRow( + row: Int, + cols: Int, + run: CellRun, + block: (CellRun) -> Unit, + ) { + var col = 0 + while (col < cols) { + val n = getCellRun(row, col, run) + if (n <= 0) break + block(run) + col += n + } + } + + /** + * Iterates all cell runs across every row, invoking [block] for each run. + * [rowStart] is called before each row with the row index. + * Default implementation loops via [scanRow]; backends may override with + * a single cross-boundary call that fetches the entire screen at once. + */ + fun scanAllRows( + rows: Int, + cols: Int, + run: CellRun, + rowStart: (row: Int) -> Unit = {}, + block: (CellRun) -> Unit, + ) { + for (row in 0 until rows) { + rowStart(row) + scanRow(row, cols, run, block) + } + } + + fun setPaletteColors( + colors: IntArray, + count: Int = colors.size.coerceAtMost(16), + ): Int + + fun setDefaultColors( + foreground: Int, + background: Int, + ): Int + + fun getLineContinuation(row: Int): Boolean + + fun setBoldHighbright(enabled: Boolean): Int +} diff --git a/lib/src/main/java/org/connectbot/terminal/TerminalCallbacks.kt b/lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalCallbacks.kt similarity index 69% rename from lib/src/main/java/org/connectbot/terminal/TerminalCallbacks.kt rename to lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalCallbacks.kt index b243031c..c7b588cd 100644 --- a/lib/src/main/java/org/connectbot/terminal/TerminalCallbacks.kt +++ b/lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalCallbacks.kt @@ -22,7 +22,7 @@ package org.connectbot.terminal * IMPORTANT: Callbacks MUST NOT call back into Terminal methods, as the native * mutex is not reentrant. This will cause a deadlock. */ -internal interface TerminalCallbacks { +interface TerminalCallbacks { /** * Called when a region of the screen needs to be redrawn. * @@ -32,7 +32,30 @@ internal interface TerminalCallbacks { * @param endCol Last column that changed (exclusive) * @return 0 on success */ - fun damage(startRow: Int, endRow: Int, startCol: Int, endCol: Int): Int + fun damage( + startRow: Int, + endRow: Int, + startCol: Int, + endCol: Int, + ): Int + + /** + * Batched damage notification: [rects] is a flat int array of (startRow, endRow, startCol, + * endCol) quads, [count] quads total. Delivered in one JNI call after a write or resize + * to eliminate per-rect JNI overhead. + * + * Default implementation loops and calls [damage] for each quad. + */ + fun damageBatch( + rects: IntArray, + count: Int, + ) { + var i = 0 + repeat(count) { + damage(rects[i], rects[i + 1], rects[i + 2], rects[i + 3]) + i += 4 + } + } /** * Called when a rectangular region needs to be moved/scrolled. @@ -43,7 +66,10 @@ internal interface TerminalCallbacks { * @param src Source rectangle * @return 1 if handled, 0 to fall back to damage events */ - fun moverect(dest: TermRect, src: TermRect): Int + fun moverect( + dest: TermRect, + src: TermRect, + ): Int /** * Called when cursor position changes. @@ -53,7 +79,11 @@ internal interface TerminalCallbacks { * @param visible Whether cursor should be visible * @return 0 on success */ - fun moveCursor(pos: CursorPosition, oldPos: CursorPosition, visible: Boolean): Int + fun moveCursor( + pos: CursorPosition, + oldPos: CursorPosition, + visible: Boolean, + ): Int /** * Called when a terminal property changes (title, cursor shape, etc.). @@ -62,7 +92,10 @@ internal interface TerminalCallbacks { * @param value Property value * @return 0 on success */ - fun setTermProp(prop: Int, value: TerminalProperty): Int + fun setTermProp( + prop: Int, + value: TerminalProperty, + ): Int /** * Called when the terminal bell should be triggered. @@ -82,7 +115,11 @@ internal interface TerminalCallbacks { * in wrapped long commands. * @return 0 on success */ - fun pushScrollbackLine(cols: Int, cells: Array, softWrapped: Boolean): Int + fun pushScrollbackLine( + cols: Int, + cells: Array, + softWrapped: Boolean, + ): Int /** * Called when a line should be popped from scrollback buffer. @@ -91,7 +128,10 @@ internal interface TerminalCallbacks { * @param cells Array to fill with screen cells * @return 0 on success */ - fun popScrollbackLine(cols: Int, cells: Array): Int + fun popScrollbackLine( + cols: Int, + cells: Array, + ): Int /** * Called when keyboard input is generated (user types, terminal generates escape sequences). @@ -112,41 +152,59 @@ internal interface TerminalCallbacks { * @param cursorCol Current cursor column from native terminal * @return 1 if handled, 0 otherwise */ - fun onOscSequence(command: Int, payload: String, cursorRow: Int, cursorCol: Int): Int + fun onOscSequence( + command: Int, + payload: String, + cursorRow: Int, + cursorCol: Int, + ): Int } /** * Rectangular region in the terminal. */ -internal data class TermRect( +data class TermRect( val startRow: Int, val endRow: Int, val startCol: Int, - val endCol: Int + val endCol: Int, ) /** * Cursor position in the terminal. */ -internal data class CursorPosition( +data class CursorPosition( val row: Int, - val col: Int + val col: Int, ) /** * Terminal property values (title, colors, cursor state, etc.). */ -internal sealed class TerminalProperty { - data class BoolValue(val value: Boolean) : TerminalProperty() - data class IntValue(val value: Int) : TerminalProperty() - data class StringValue(val value: String) : TerminalProperty() - data class ColorValue(val red: Int, val green: Int, val blue: Int) : TerminalProperty() +sealed class TerminalProperty { + data class BoolValue( + val value: Boolean, + ) : TerminalProperty() + + data class IntValue( + val value: Int, + ) : TerminalProperty() + + data class StringValue( + val value: String, + ) : TerminalProperty() + + data class ColorValue( + val red: Int, + val green: Int, + val blue: Int, + ) : TerminalProperty() } /** * A single screen cell with character and attributes. */ -internal data class ScreenCell( +data class ScreenCell( val char: Char, val combiningChars: List = emptyList(), val fgRed: Int, @@ -157,8 +215,10 @@ internal data class ScreenCell( val bgBlue: Int, val bold: Boolean = false, val italic: Boolean = false, - val underline: Int = 0, // 0=none, 1=single, 2=double + // 0=none, 1=single, 2=double + val underline: Int = 0, val reverse: Boolean = false, val strike: Boolean = false, - val width: Int = 1 // 1 for normal, 2 for fullwidth (CJK) + // 1 for normal, 2 for fullwidth (CJK) + val width: Int = 1, ) diff --git a/lib-native/build.gradle.kts b/lib-native/build.gradle.kts new file mode 100644 index 00000000..0237561d --- /dev/null +++ b/lib-native/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) + id("termlib-publish") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +val hostJniDir = layout.buildDirectory.dir("host-jni") +val cppSourceDir = layout.projectDirectory.dir("src/main/cpp") + +val cmakeConfigureHost by tasks.registering(Exec::class) { + group = "build" + description = "Configure the CMake host build of jni_cb_term" + outputs.dir(hostJniDir) + commandLine( + "cmake", + "-S", + cppSourceDir.asFile.absolutePath, + "-B", + hostJniDir.get().asFile.absolutePath, + "-DCMAKE_BUILD_TYPE=Release", + ) +} + +val cmakeBuildHost by tasks.registering(Exec::class) { + group = "build" + description = "Build libjni_cb_term for the host JVM" + dependsOn(cmakeConfigureHost) + commandLine( + "cmake", + "--build", + hostJniDir.get().asFile.absolutePath, + "--target", + "jni_cb_term", + ) + outputs.dir(hostJniDir) +} + +tasks.withType { + dependsOn(cmakeBuildHost) + jvmArgs("-Djava.library.path=${hostJniDir.get().asFile.absolutePath}") +} + +dependencies { + implementation(project(":lib-intf")) + + testImplementation(libs.junit) + testImplementation(libs.mockk) +} + +mavenPublishing { + coordinates(groupId = "org.connectbot", artifactId = "termlib-native") + + pom { + name.set("termlib-native") + description.set("ConnectBot terminal library JNI bindings") + inceptionYear.set("2026") + } +} diff --git a/lib-native/src/main/cpp/CMakeLists.txt b/lib-native/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..934b0fac --- /dev/null +++ b/lib-native/src/main/cpp/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.18.1) + +project("cb_term") + +set(CPP_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + +add_library(vterm STATIC + ${CPP_DIR}/libvterm/src/encoding.c + ${CPP_DIR}/libvterm/src/keyboard.c + ${CPP_DIR}/libvterm/src/mouse.c + ${CPP_DIR}/libvterm/src/parser.c + ${CPP_DIR}/libvterm/src/pen.c + ${CPP_DIR}/libvterm/src/screen.c + ${CPP_DIR}/libvterm/src/state.c + ${CPP_DIR}/libvterm/src/unicode.c + ${CPP_DIR}/libvterm/src/vterm.c +) + +target_include_directories(vterm PUBLIC ${CPP_DIR}/libvterm/include) +target_compile_definitions(vterm PRIVATE VTERM_STATIC) +set_target_properties(vterm PROPERTIES POSITION_INDEPENDENT_CODE ON) + +add_library(jni_cb_term SHARED + ${CPP_DIR}/Terminal.cpp + ${CPP_DIR}/mutf8.cpp +) + +target_include_directories(jni_cb_term PRIVATE + ${CPP_DIR} + ${CPP_DIR}/libvterm/include +) + +target_compile_features(jni_cb_term PRIVATE cxx_std_17) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_options(jni_cb_term PRIVATE "-Wl,-z,max-page-size=16384") +endif() + +if(ANDROID) + target_link_libraries(jni_cb_term vterm android log) +else() + find_package(JNI REQUIRED) + target_include_directories(jni_cb_term PRIVATE ${JNI_INCLUDE_DIRS}) + target_link_libraries(jni_cb_term vterm) +endif() diff --git a/lib/src/main/cpp/Terminal.cpp b/lib-native/src/main/cpp/Terminal.cpp similarity index 96% rename from lib/src/main/cpp/Terminal.cpp rename to lib-native/src/main/cpp/Terminal.cpp index 035612fa..7de29920 100644 --- a/lib/src/main/cpp/Terminal.cpp +++ b/lib-native/src/main/cpp/Terminal.cpp @@ -63,6 +63,11 @@ Terminal::Terminal(JNIEnv* env, jobject callbacks, int rows, int cols) LOGE("Failed to find damage method"); env->ExceptionClear(); } + mDamageBatchMethod = env->GetMethodID(callbacksClass, "damageBatch", "([II)V"); + if (!mDamageBatchMethod || env->ExceptionCheck()) { + LOGE("Failed to find damageBatch method"); + env->ExceptionClear(); + } mMoverectMethod = env->GetMethodID(callbacksClass, "moverect", "(Lorg/connectbot/terminal/TermRect;Lorg/connectbot/terminal/TermRect;)I"); if (!mMoverectMethod) { @@ -277,12 +282,21 @@ int Terminal::writeInput(const uint8_t* data, size_t length) { return 0; } - // Feed data to libvterm for processing - size_t written = vterm_input_write(mVt, (const char*)data, length); + mInBatchedWrite = true; + mDamageCount = 0; - // Flush screen state to trigger callbacks + size_t written = vterm_input_write(mVt, (const char*)data, length); vterm_screen_flush_damage(mVts); + mInBatchedWrite = false; + + if (mDamageCount > 0 && mDamageBatchMethod) { + JNIEnv* env; + if (mJavaVM->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) { + flushDamageBatch(env); + } + } + return static_cast(written); } @@ -294,8 +308,20 @@ int Terminal::resize(int rows, int cols) { mCols = cols; if (mVt) { + mInBatchedWrite = true; + mDamageCount = 0; + vterm_set_size(mVt, rows, cols); vterm_screen_flush_damage(mVts); + + mInBatchedWrite = false; + + if (mDamageCount > 0 && mDamageBatchMethod) { + JNIEnv* env; + if (mJavaVM->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) { + flushDamageBatch(env); + } + } } return 0; @@ -667,6 +693,24 @@ int Terminal::termSelectionQuery(VTermSelectionMask mask, void* user) { // Java callback invocations void Terminal::invokeDamage(int startRow, int endRow, int startCol, int endCol) { + if (mInBatchedWrite) { + if (mDamageCount < MAX_DAMAGE_RECTS) { + int i = mDamageCount * 4; + mDamageRects[i] = startRow; + mDamageRects[i + 1] = endRow; + mDamageRects[i + 2] = startCol; + mDamageRects[i + 3] = endCol; + mDamageCount++; + } else { + // Buffer full — flush immediately so the UI stays responsive. + JNIEnv* env; + if (mJavaVM->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) { + flushDamageBatch(env); + } + } + return; + } + if (!mDamageMethod) { return; } @@ -680,6 +724,17 @@ void Terminal::invokeDamage(int startRow, int endRow, int startCol, int endCol) JNI_CHECK_EXCEPTION(env); } +void Terminal::flushDamageBatch(JNIEnv* env) { + int n = mDamageCount; + mDamageCount = 0; + if (n == 0 || !mDamageBatchMethod) return; + + ScopedLocalRef arr(env, env->NewIntArray(n * 4)); + env->SetIntArrayRegion(arr, 0, n * 4, mDamageRects); + env->CallVoidMethod(mCallbacks, mDamageBatchMethod, arr.get(), n); + JNI_CHECK_EXCEPTION(env); +} + int Terminal::invokeMoverect(VTermRect dest, VTermRect src) { if (!mMoverectMethod) { return 0; diff --git a/lib/src/main/cpp/Terminal.h b/lib-native/src/main/cpp/Terminal.h similarity index 95% rename from lib/src/main/cpp/Terminal.h rename to lib-native/src/main/cpp/Terminal.h index a5a5cf2a..4bea89e3 100644 --- a/lib/src/main/cpp/Terminal.h +++ b/lib-native/src/main/cpp/Terminal.h @@ -96,6 +96,7 @@ class Terminal { // Java callback invocation helpers void invokeDamage(int startRow, int endRow, int startCol, int endCol); + void flushDamageBatch(JNIEnv* env); int invokeMoverect(VTermRect dest, VTermRect src); void invokeMoveCursor(int row, int col, int oldRow, int oldCol, bool visible); void invokeSetTermProp(VTermProp prop, VTermValue* val); @@ -134,6 +135,13 @@ class Terminal { JavaVM* mJavaVM{}; jobject mCallbacks; // Global reference jmethodID mDamageMethod; + jmethodID mDamageBatchMethod; + + // Damage accumulator: flat (startRow,endRow,startCol,endCol) quads, max 256 rects + static constexpr int MAX_DAMAGE_RECTS = 256; + int mDamageRects[MAX_DAMAGE_RECTS * 4]{}; + int mDamageCount{0}; + bool mInBatchedWrite{false}; jmethodID mMoverectMethod; jmethodID mMoveCursorMethod; jmethodID mSetTermPropMethod; diff --git a/lib/src/main/cpp/libvterm/CONTRIBUTING b/lib-native/src/main/cpp/libvterm/CONTRIBUTING similarity index 100% rename from lib/src/main/cpp/libvterm/CONTRIBUTING rename to lib-native/src/main/cpp/libvterm/CONTRIBUTING diff --git a/lib/src/main/cpp/libvterm/LICENSE b/lib-native/src/main/cpp/libvterm/LICENSE similarity index 100% rename from lib/src/main/cpp/libvterm/LICENSE rename to lib-native/src/main/cpp/libvterm/LICENSE diff --git a/lib/src/main/cpp/libvterm/Makefile b/lib-native/src/main/cpp/libvterm/Makefile similarity index 100% rename from lib/src/main/cpp/libvterm/Makefile rename to lib-native/src/main/cpp/libvterm/Makefile diff --git a/lib/src/main/cpp/libvterm/bin/unterm.c b/lib-native/src/main/cpp/libvterm/bin/unterm.c similarity index 100% rename from lib/src/main/cpp/libvterm/bin/unterm.c rename to lib-native/src/main/cpp/libvterm/bin/unterm.c diff --git a/lib/src/main/cpp/libvterm/bin/vterm-ctrl.c b/lib-native/src/main/cpp/libvterm/bin/vterm-ctrl.c similarity index 100% rename from lib/src/main/cpp/libvterm/bin/vterm-ctrl.c rename to lib-native/src/main/cpp/libvterm/bin/vterm-ctrl.c diff --git a/lib/src/main/cpp/libvterm/bin/vterm-dump.c b/lib-native/src/main/cpp/libvterm/bin/vterm-dump.c similarity index 100% rename from lib/src/main/cpp/libvterm/bin/vterm-dump.c rename to lib-native/src/main/cpp/libvterm/bin/vterm-dump.c diff --git a/lib/src/main/cpp/libvterm/include/vterm.h b/lib-native/src/main/cpp/libvterm/include/vterm.h similarity index 100% rename from lib/src/main/cpp/libvterm/include/vterm.h rename to lib-native/src/main/cpp/libvterm/include/vterm.h diff --git a/lib/src/main/cpp/libvterm/include/vterm_keycodes.h b/lib-native/src/main/cpp/libvterm/include/vterm_keycodes.h similarity index 100% rename from lib/src/main/cpp/libvterm/include/vterm_keycodes.h rename to lib-native/src/main/cpp/libvterm/include/vterm_keycodes.h diff --git a/lib/src/main/cpp/libvterm/src/encoding.c b/lib-native/src/main/cpp/libvterm/src/encoding.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/encoding.c rename to lib-native/src/main/cpp/libvterm/src/encoding.c diff --git a/lib/src/main/cpp/libvterm/src/encoding/DECdrawing.inc b/lib-native/src/main/cpp/libvterm/src/encoding/DECdrawing.inc similarity index 100% rename from lib/src/main/cpp/libvterm/src/encoding/DECdrawing.inc rename to lib-native/src/main/cpp/libvterm/src/encoding/DECdrawing.inc diff --git a/lib/src/main/cpp/libvterm/src/encoding/uk.inc b/lib-native/src/main/cpp/libvterm/src/encoding/uk.inc similarity index 100% rename from lib/src/main/cpp/libvterm/src/encoding/uk.inc rename to lib-native/src/main/cpp/libvterm/src/encoding/uk.inc diff --git a/lib/src/main/cpp/libvterm/src/fullwidth.inc b/lib-native/src/main/cpp/libvterm/src/fullwidth.inc similarity index 100% rename from lib/src/main/cpp/libvterm/src/fullwidth.inc rename to lib-native/src/main/cpp/libvterm/src/fullwidth.inc diff --git a/lib/src/main/cpp/libvterm/src/keyboard.c b/lib-native/src/main/cpp/libvterm/src/keyboard.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/keyboard.c rename to lib-native/src/main/cpp/libvterm/src/keyboard.c diff --git a/lib/src/main/cpp/libvterm/src/mouse.c b/lib-native/src/main/cpp/libvterm/src/mouse.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/mouse.c rename to lib-native/src/main/cpp/libvterm/src/mouse.c diff --git a/lib/src/main/cpp/libvterm/src/parser.c b/lib-native/src/main/cpp/libvterm/src/parser.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/parser.c rename to lib-native/src/main/cpp/libvterm/src/parser.c diff --git a/lib/src/main/cpp/libvterm/src/pen.c b/lib-native/src/main/cpp/libvterm/src/pen.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/pen.c rename to lib-native/src/main/cpp/libvterm/src/pen.c diff --git a/lib/src/main/cpp/libvterm/src/rect.h b/lib-native/src/main/cpp/libvterm/src/rect.h similarity index 100% rename from lib/src/main/cpp/libvterm/src/rect.h rename to lib-native/src/main/cpp/libvterm/src/rect.h diff --git a/lib/src/main/cpp/libvterm/src/screen.c b/lib-native/src/main/cpp/libvterm/src/screen.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/screen.c rename to lib-native/src/main/cpp/libvterm/src/screen.c diff --git a/lib/src/main/cpp/libvterm/src/state.c b/lib-native/src/main/cpp/libvterm/src/state.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/state.c rename to lib-native/src/main/cpp/libvterm/src/state.c diff --git a/lib/src/main/cpp/libvterm/src/unicode.c b/lib-native/src/main/cpp/libvterm/src/unicode.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/unicode.c rename to lib-native/src/main/cpp/libvterm/src/unicode.c diff --git a/lib/src/main/cpp/libvterm/src/utf8.h b/lib-native/src/main/cpp/libvterm/src/utf8.h similarity index 100% rename from lib/src/main/cpp/libvterm/src/utf8.h rename to lib-native/src/main/cpp/libvterm/src/utf8.h diff --git a/lib/src/main/cpp/libvterm/src/vterm.c b/lib-native/src/main/cpp/libvterm/src/vterm.c similarity index 100% rename from lib/src/main/cpp/libvterm/src/vterm.c rename to lib-native/src/main/cpp/libvterm/src/vterm.c diff --git a/lib/src/main/cpp/libvterm/src/vterm_internal.h b/lib-native/src/main/cpp/libvterm/src/vterm_internal.h similarity index 100% rename from lib/src/main/cpp/libvterm/src/vterm_internal.h rename to lib-native/src/main/cpp/libvterm/src/vterm_internal.h diff --git a/lib/src/main/cpp/libvterm/t/02parser.test b/lib-native/src/main/cpp/libvterm/t/02parser.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/02parser.test rename to lib-native/src/main/cpp/libvterm/t/02parser.test diff --git a/lib/src/main/cpp/libvterm/t/03encoding_utf8.test b/lib-native/src/main/cpp/libvterm/t/03encoding_utf8.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/03encoding_utf8.test rename to lib-native/src/main/cpp/libvterm/t/03encoding_utf8.test diff --git a/lib/src/main/cpp/libvterm/t/10state_putglyph.test b/lib-native/src/main/cpp/libvterm/t/10state_putglyph.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/10state_putglyph.test rename to lib-native/src/main/cpp/libvterm/t/10state_putglyph.test diff --git a/lib/src/main/cpp/libvterm/t/11state_movecursor.test b/lib-native/src/main/cpp/libvterm/t/11state_movecursor.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/11state_movecursor.test rename to lib-native/src/main/cpp/libvterm/t/11state_movecursor.test diff --git a/lib/src/main/cpp/libvterm/t/12state_scroll.test b/lib-native/src/main/cpp/libvterm/t/12state_scroll.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/12state_scroll.test rename to lib-native/src/main/cpp/libvterm/t/12state_scroll.test diff --git a/lib/src/main/cpp/libvterm/t/13state_edit.test b/lib-native/src/main/cpp/libvterm/t/13state_edit.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/13state_edit.test rename to lib-native/src/main/cpp/libvterm/t/13state_edit.test diff --git a/lib/src/main/cpp/libvterm/t/14state_encoding.test b/lib-native/src/main/cpp/libvterm/t/14state_encoding.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/14state_encoding.test rename to lib-native/src/main/cpp/libvterm/t/14state_encoding.test diff --git a/lib/src/main/cpp/libvterm/t/15state_mode.test b/lib-native/src/main/cpp/libvterm/t/15state_mode.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/15state_mode.test rename to lib-native/src/main/cpp/libvterm/t/15state_mode.test diff --git a/lib/src/main/cpp/libvterm/t/16state_resize.test b/lib-native/src/main/cpp/libvterm/t/16state_resize.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/16state_resize.test rename to lib-native/src/main/cpp/libvterm/t/16state_resize.test diff --git a/lib/src/main/cpp/libvterm/t/17state_mouse.test b/lib-native/src/main/cpp/libvterm/t/17state_mouse.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/17state_mouse.test rename to lib-native/src/main/cpp/libvterm/t/17state_mouse.test diff --git a/lib/src/main/cpp/libvterm/t/18state_termprops.test b/lib-native/src/main/cpp/libvterm/t/18state_termprops.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/18state_termprops.test rename to lib-native/src/main/cpp/libvterm/t/18state_termprops.test diff --git a/lib/src/main/cpp/libvterm/t/20state_wrapping.test b/lib-native/src/main/cpp/libvterm/t/20state_wrapping.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/20state_wrapping.test rename to lib-native/src/main/cpp/libvterm/t/20state_wrapping.test diff --git a/lib/src/main/cpp/libvterm/t/21state_tabstops.test b/lib-native/src/main/cpp/libvterm/t/21state_tabstops.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/21state_tabstops.test rename to lib-native/src/main/cpp/libvterm/t/21state_tabstops.test diff --git a/lib/src/main/cpp/libvterm/t/22state_save.test b/lib-native/src/main/cpp/libvterm/t/22state_save.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/22state_save.test rename to lib-native/src/main/cpp/libvterm/t/22state_save.test diff --git a/lib/src/main/cpp/libvterm/t/25state_input.test b/lib-native/src/main/cpp/libvterm/t/25state_input.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/25state_input.test rename to lib-native/src/main/cpp/libvterm/t/25state_input.test diff --git a/lib/src/main/cpp/libvterm/t/26state_query.test b/lib-native/src/main/cpp/libvterm/t/26state_query.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/26state_query.test rename to lib-native/src/main/cpp/libvterm/t/26state_query.test diff --git a/lib/src/main/cpp/libvterm/t/27state_reset.test b/lib-native/src/main/cpp/libvterm/t/27state_reset.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/27state_reset.test rename to lib-native/src/main/cpp/libvterm/t/27state_reset.test diff --git a/lib/src/main/cpp/libvterm/t/28state_dbl_wh.test b/lib-native/src/main/cpp/libvterm/t/28state_dbl_wh.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/28state_dbl_wh.test rename to lib-native/src/main/cpp/libvterm/t/28state_dbl_wh.test diff --git a/lib/src/main/cpp/libvterm/t/29state_fallback.test b/lib-native/src/main/cpp/libvterm/t/29state_fallback.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/29state_fallback.test rename to lib-native/src/main/cpp/libvterm/t/29state_fallback.test diff --git a/lib/src/main/cpp/libvterm/t/30state_pen.test b/lib-native/src/main/cpp/libvterm/t/30state_pen.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/30state_pen.test rename to lib-native/src/main/cpp/libvterm/t/30state_pen.test diff --git a/lib/src/main/cpp/libvterm/t/31state_rep.test b/lib-native/src/main/cpp/libvterm/t/31state_rep.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/31state_rep.test rename to lib-native/src/main/cpp/libvterm/t/31state_rep.test diff --git a/lib/src/main/cpp/libvterm/t/32state_flow.test b/lib-native/src/main/cpp/libvterm/t/32state_flow.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/32state_flow.test rename to lib-native/src/main/cpp/libvterm/t/32state_flow.test diff --git a/lib/src/main/cpp/libvterm/t/40state_selection.test b/lib-native/src/main/cpp/libvterm/t/40state_selection.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/40state_selection.test rename to lib-native/src/main/cpp/libvterm/t/40state_selection.test diff --git a/lib/src/main/cpp/libvterm/t/60screen_ascii.test b/lib-native/src/main/cpp/libvterm/t/60screen_ascii.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/60screen_ascii.test rename to lib-native/src/main/cpp/libvterm/t/60screen_ascii.test diff --git a/lib/src/main/cpp/libvterm/t/61screen_unicode.test b/lib-native/src/main/cpp/libvterm/t/61screen_unicode.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/61screen_unicode.test rename to lib-native/src/main/cpp/libvterm/t/61screen_unicode.test diff --git a/lib/src/main/cpp/libvterm/t/62screen_damage.test b/lib-native/src/main/cpp/libvterm/t/62screen_damage.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/62screen_damage.test rename to lib-native/src/main/cpp/libvterm/t/62screen_damage.test diff --git a/lib/src/main/cpp/libvterm/t/63screen_resize.test b/lib-native/src/main/cpp/libvterm/t/63screen_resize.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/63screen_resize.test rename to lib-native/src/main/cpp/libvterm/t/63screen_resize.test diff --git a/lib/src/main/cpp/libvterm/t/64screen_pen.test b/lib-native/src/main/cpp/libvterm/t/64screen_pen.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/64screen_pen.test rename to lib-native/src/main/cpp/libvterm/t/64screen_pen.test diff --git a/lib/src/main/cpp/libvterm/t/65screen_protect.test b/lib-native/src/main/cpp/libvterm/t/65screen_protect.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/65screen_protect.test rename to lib-native/src/main/cpp/libvterm/t/65screen_protect.test diff --git a/lib/src/main/cpp/libvterm/t/66screen_extent.test b/lib-native/src/main/cpp/libvterm/t/66screen_extent.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/66screen_extent.test rename to lib-native/src/main/cpp/libvterm/t/66screen_extent.test diff --git a/lib/src/main/cpp/libvterm/t/67screen_dbl_wh.test b/lib-native/src/main/cpp/libvterm/t/67screen_dbl_wh.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/67screen_dbl_wh.test rename to lib-native/src/main/cpp/libvterm/t/67screen_dbl_wh.test diff --git a/lib/src/main/cpp/libvterm/t/68screen_termprops.test b/lib-native/src/main/cpp/libvterm/t/68screen_termprops.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/68screen_termprops.test rename to lib-native/src/main/cpp/libvterm/t/68screen_termprops.test diff --git a/lib/src/main/cpp/libvterm/t/69screen_reflow.test b/lib-native/src/main/cpp/libvterm/t/69screen_reflow.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/69screen_reflow.test rename to lib-native/src/main/cpp/libvterm/t/69screen_reflow.test diff --git a/lib/src/main/cpp/libvterm/t/69screen_sb_clear.test b/lib-native/src/main/cpp/libvterm/t/69screen_sb_clear.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/69screen_sb_clear.test rename to lib-native/src/main/cpp/libvterm/t/69screen_sb_clear.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_01-movement-1.test b/lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-1.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_01-movement-1.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-1.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_01-movement-2.test b/lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-2.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_01-movement-2.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-2.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_01-movement-3.test b/lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-3.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_01-movement-3.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-3.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_01-movement-4.test b/lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-4.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_01-movement-4.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_01-movement-4.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_02-screen-1.test b/lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-1.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_02-screen-1.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-1.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_02-screen-2.test b/lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-2.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_02-screen-2.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-2.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_02-screen-3.test b/lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-3.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_02-screen-3.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-3.test diff --git a/lib/src/main/cpp/libvterm/t/90vttest_02-screen-4.test b/lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-4.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/90vttest_02-screen-4.test rename to lib-native/src/main/cpp/libvterm/t/90vttest_02-screen-4.test diff --git a/lib/src/main/cpp/libvterm/t/92lp1640917.test b/lib-native/src/main/cpp/libvterm/t/92lp1640917.test similarity index 100% rename from lib/src/main/cpp/libvterm/t/92lp1640917.test rename to lib-native/src/main/cpp/libvterm/t/92lp1640917.test diff --git a/lib/src/main/cpp/libvterm/t/harness.c b/lib-native/src/main/cpp/libvterm/t/harness.c similarity index 100% rename from lib/src/main/cpp/libvterm/t/harness.c rename to lib-native/src/main/cpp/libvterm/t/harness.c diff --git a/lib/src/main/cpp/libvterm/t/run-test.pl b/lib-native/src/main/cpp/libvterm/t/run-test.pl similarity index 100% rename from lib/src/main/cpp/libvterm/t/run-test.pl rename to lib-native/src/main/cpp/libvterm/t/run-test.pl diff --git a/lib/src/main/cpp/libvterm/vterm.pc.in b/lib-native/src/main/cpp/libvterm/vterm.pc.in similarity index 100% rename from lib/src/main/cpp/libvterm/vterm.pc.in rename to lib-native/src/main/cpp/libvterm/vterm.pc.in diff --git a/lib/src/main/cpp/mutf8.cpp b/lib-native/src/main/cpp/mutf8.cpp similarity index 100% rename from lib/src/main/cpp/mutf8.cpp rename to lib-native/src/main/cpp/mutf8.cpp diff --git a/lib/src/main/cpp/mutf8.h b/lib-native/src/main/cpp/mutf8.h similarity index 100% rename from lib/src/main/cpp/mutf8.h rename to lib-native/src/main/cpp/mutf8.h diff --git a/lib-native/src/main/kotlin/org/connectbot/terminal/TerminalNative.kt b/lib-native/src/main/kotlin/org/connectbot/terminal/TerminalNative.kt new file mode 100644 index 00000000..1959f096 --- /dev/null +++ b/lib-native/src/main/kotlin/org/connectbot/terminal/TerminalNative.kt @@ -0,0 +1,198 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal + +import java.nio.ByteBuffer + +class TerminalNative( + callbacks: TerminalCallbacks, +) : TerminalBackend { + private var nativePtr: Long = 0 + + init { + nativePtr = nativeInit(callbacks) + if (nativePtr == 0L) { + throw RuntimeException("Failed to initialize native terminal") + } + } + + fun writeInput( + buffer: ByteBuffer, + length: Int, + ): Int { + checkNotClosed() + return nativeWriteInputBuffer(nativePtr, buffer, length) + } + + override fun writeInput( + data: ByteArray, + offset: Int, + length: Int, + ): Int { + checkNotClosed() + return nativeWriteInputArray(nativePtr, data, offset, length) + } + + override fun resize( + rows: Int, + cols: Int, + ): Int { + checkNotClosed() + return nativeResize(nativePtr, rows, cols) + } + + override fun dispatchKey( + modifiers: Int, + key: Int, + ): Boolean { + checkNotClosed() + return nativeDispatchKey(nativePtr, modifiers, key) + } + + override fun dispatchCharacter( + modifiers: Int, + codepoint: Int, + ): Boolean { + checkNotClosed() + return nativeDispatchCharacter(nativePtr, modifiers, codepoint) + } + + override fun getCellRun( + row: Int, + col: Int, + run: CellRun, + ): Int { + checkNotClosed() + return nativeGetCellRun(nativePtr, row, col, run) + } + + override fun setPaletteColors( + colors: IntArray, + count: Int, + ): Int { + checkNotClosed() + require(count <= 16) { "Can only set up to 16 ANSI palette colors" } + require(colors.size >= count) { "Color array too small for requested count" } + return nativeSetPaletteColors(nativePtr, colors, count) + } + + override fun setDefaultColors( + foreground: Int, + background: Int, + ): Int { + checkNotClosed() + return nativeSetDefaultColors(nativePtr, foreground, background) + } + + override fun getLineContinuation(row: Int): Boolean { + checkNotClosed() + return nativeGetLineContinuation(nativePtr, row) + } + + override fun setBoldHighbright(enabled: Boolean): Int { + checkNotClosed() + return nativeSetBoldHighbright(nativePtr, enabled) + } + + override fun close() { + if (nativePtr != 0L) { + nativeDestroy(nativePtr) + nativePtr = 0 + } + } + + private fun checkNotClosed() { + if (nativePtr == 0L) throw IllegalStateException("Terminal has been closed") + } + + @Suppress("unused") + protected fun finalize() = close() + + private external fun nativeInit(callbacks: TerminalCallbacks): Long + + private external fun nativeDestroy(ptr: Long): Int + + private external fun nativeWriteInputBuffer( + ptr: Long, + buffer: ByteBuffer, + length: Int, + ): Int + + private external fun nativeWriteInputArray( + ptr: Long, + data: ByteArray, + offset: Int, + length: Int, + ): Int + + private external fun nativeResize( + ptr: Long, + rows: Int, + cols: Int, + ): Int + + private external fun nativeDispatchKey( + ptr: Long, + modifiers: Int, + key: Int, + ): Boolean + + private external fun nativeDispatchCharacter( + ptr: Long, + modifiers: Int, + character: Int, + ): Boolean + + private external fun nativeGetCellRun( + ptr: Long, + row: Int, + col: Int, + run: CellRun, + ): Int + + private external fun nativeSetPaletteColors( + ptr: Long, + colors: IntArray, + count: Int, + ): Int + + private external fun nativeSetDefaultColors( + ptr: Long, + fgColor: Int, + bgColor: Int, + ): Int + + private external fun nativeGetLineContinuation( + ptr: Long, + row: Int, + ): Boolean + + private external fun nativeSetBoldHighbright( + ptr: Long, + enabled: Boolean, + ): Int + + companion object { + init { + try { + System.loadLibrary("jni_cb_term") + } catch (e: Exception) { + System.err.println("Failed to load JNI library: ${e.message}") + } + } + } +} diff --git a/lib-wasm/build.gradle.kts b/lib-wasm/build.gradle.kts new file mode 100644 index 00000000..d4aa22c1 --- /dev/null +++ b/lib-wasm/build.gradle.kts @@ -0,0 +1,116 @@ +import at.released.wasm2class.InterpreterFallback +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.wasm2class) + id("termlib-publish") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +// ------------------------------------------------------------------------- +// WASI build: compile libvterm + vterm_wasm.c → libvterm.wasm +// +// Prerequisites: wasi-sdk installed at /opt/wasi-sdk (or WASI_SDK_PREFIX env var). +// Run manually or as part of CI before the Gradle build: +// +// ./gradlew :lib-wasm:buildWasm +// +// The produced wasm is checked in at src/main/resources/libvterm.wasm so that +// downstream consumers don't need wasi-sdk installed. +// ------------------------------------------------------------------------- +val wasiSdkPrefix = System.getenv("WASI_SDK_PREFIX") ?: "/opt/wasi-sdk" +val wasmBuildDir = layout.buildDirectory.dir("wasm-cmake") +val wasmOutputFile = layout.projectDirectory.file("src/main/resources/libvterm.wasm") +val cppSourceDir = layout.projectDirectory.dir("src/main/cpp") + +val cmakeConfigureWasm by tasks.registering(Exec::class) { + group = "build" + description = "Configure CMake WASI build for libvterm.wasm" + outputs.dir(wasmBuildDir) + commandLine( + "cmake", + "-S", + cppSourceDir.asFile.absolutePath, + "-B", + wasmBuildDir.get().asFile.absolutePath, + "-DCMAKE_TOOLCHAIN_FILE=$wasiSdkPrefix/share/cmake/wasi-sdk-p1.cmake", + "-DWASI_SDK_PREFIX=$wasiSdkPrefix", + "-DCMAKE_BUILD_TYPE=Release", + ) +} + +val buildWasm by tasks.registering(Exec::class) { + group = "build" + description = "Build libvterm.wasm using WASI SDK" + dependsOn(cmakeConfigureWasm) + commandLine( + "cmake", + "--build", + wasmBuildDir.get().asFile.absolutePath, + "--target", + "vterm_wasm", + ) + outputs.file(wasmOutputFile) + doLast { + val built = wasmBuildDir.get().file("vterm_wasm.wasm").asFile + if (built.exists()) { + built.copyTo(wasmOutputFile.asFile, overwrite = true) + } + } +} + +// ------------------------------------------------------------------------- +// wasm2class AOT compilation +// Converts the checked-in libvterm.wasm to JVM bytecode at Gradle build time. +// No wasi-sdk needed for this step — only the .wasm file is required. +// ------------------------------------------------------------------------- +wasm2class { + targetPackage = "org.connectbot.terminal.wasm.generated" + modules { + create("LibVterm") { + wasm = wasmOutputFile.asFile + // Large functions in libvterm may exceed JVM method size limit; + // fail the build when that happens + interpreterFallback = InterpreterFallback.FAIL + } + } +} + +dependencies { + compileOnly(project(":lib-intf")) + implementation(libs.chicory.wasi) + compileOnly(project(":lib-native")) + compileOnly(libs.robolectric) + annotationProcessor(libs.robolectric) + + testImplementation(project(":lib-intf")) + testImplementation(libs.junit) + testImplementation(libs.mockk) + + // Override the build-time Chicory compiler bundled with wasm2class (1.5.1) to match + // the runtime version. The plugin uses defaultDependencies, so adding our own dependency + // to the chicoryCompiler configuration before resolution takes precedence. + add("chicoryCompiler", libs.chicory.buildTimeCompiler) +} + +mavenPublishing { + coordinates(groupId = "org.connectbot", artifactId = "termlib-host") + + pom { + name.set("termlib-host") + description.set("Robolectric host-testing support for termlib using a WASM backend (no native .so required)") + inceptionYear.set("2026") + } +} diff --git a/lib-wasm/src/main/cpp/CMakeLists.txt b/lib-wasm/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..4c8fd652 --- /dev/null +++ b/lib-wasm/src/main/cpp/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.18) +project(vterm_wasm C) + +# This CMake file is only used with the WASI SDK toolchain to produce +# libvterm.wasm. Invoke with: +# +# cmake -S . -B build-wasm \ +# -DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake \ +# -DWASI_SDK_PREFIX=/opt/wasi-sdk +# cmake --build build-wasm --target vterm_wasm + +set(LIBVTERM_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../lib-native/src/main/cpp/libvterm") + +add_library(vterm STATIC + ${LIBVTERM_DIR}/src/encoding.c + ${LIBVTERM_DIR}/src/keyboard.c + ${LIBVTERM_DIR}/src/mouse.c + ${LIBVTERM_DIR}/src/parser.c + ${LIBVTERM_DIR}/src/pen.c + ${LIBVTERM_DIR}/src/screen.c + ${LIBVTERM_DIR}/src/state.c + ${LIBVTERM_DIR}/src/unicode.c + ${LIBVTERM_DIR}/src/vterm.c +) +target_include_directories(vterm PUBLIC ${LIBVTERM_DIR}/include) +target_compile_definitions(vterm PRIVATE VTERM_STATIC) + +add_executable(vterm_wasm + ${CMAKE_CURRENT_SOURCE_DIR}/vterm_wasm.c +) +target_include_directories(vterm_wasm PRIVATE ${LIBVTERM_DIR}/include) +target_link_libraries(vterm_wasm vterm) + +# Strip to minimum size; keep only the exported symbols +target_link_options(vterm_wasm PRIVATE + -Wl,--no-entry + -Wl,--export-dynamic + -nostartfiles +) + +# Output a .wasm file (not an executable) +set_target_properties(vterm_wasm PROPERTIES SUFFIX ".wasm") diff --git a/lib-wasm/src/main/cpp/vterm_wasm.c b/lib-wasm/src/main/cpp/vterm_wasm.c new file mode 100644 index 00000000..deaf7a03 --- /dev/null +++ b/lib-wasm/src/main/cpp/vterm_wasm.c @@ -0,0 +1,647 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ + +/* + * Pure-C Wasm shim for libvterm. + * + * Exports plain C functions that the Java host calls via Chicory. + * Imports callback functions that Java provides as Chicory host functions. + * No JNI, no JavaVM*, no Android dependencies. + * + * All pointers are i32 (Wasm linear memory addresses). + * Strings passed to Java are (ptr, len) pairs; Java reads them from Wasm memory. + */ + +#include +#include +#include +#include "vterm.h" + +/* ------------------------------------------------------------------------- + * Imported callbacks (implemented in Java as Chicory host functions) + * ------------------------------------------------------------------------- */ + +/* Module name used for all imports */ +#define IMPORT_MODULE "vterm_cb" + +__attribute__((import_module(IMPORT_MODULE), import_name("damage"))) +extern int cb_damage(int startRow, int endRow, int startCol, int endCol); + +__attribute__((import_module(IMPORT_MODULE), import_name("moverect"))) +extern int cb_moverect(int dstStartRow, int dstEndRow, int dstStartCol, int dstEndCol, + int srcStartRow, int srcEndRow, int srcStartCol, int srcEndCol); + +__attribute__((import_module(IMPORT_MODULE), import_name("movecursor"))) +extern int cb_movecursor(int row, int col, int oldRow, int oldCol, int visible); + +__attribute__((import_module(IMPORT_MODULE), import_name("settermprop"))) +extern int cb_settermprop(int prop, int type, int iVal, int ptr, int len); + +__attribute__((import_module(IMPORT_MODULE), import_name("bell"))) +extern int cb_bell(void); + +__attribute__((import_module(IMPORT_MODULE), import_name("sb_pushline"))) +extern int cb_sb_pushline(int cols, int cellsPtr, int softWrapped); + +__attribute__((import_module(IMPORT_MODULE), import_name("sb_popline"))) +extern int cb_sb_popline(int cols, int cellsPtr); + +__attribute__((import_module(IMPORT_MODULE), import_name("output"))) +extern void cb_output(int ptr, int len); + +__attribute__((import_module(IMPORT_MODULE), import_name("osc"))) +extern int cb_osc(int command, int ptr, int len, int cursorRow, int cursorCol); + +/* ------------------------------------------------------------------------- + * Packed cell layout for scrollback exchange + * + * Each cell is a fixed-size struct in Wasm linear memory that Java reads + * directly. The layout must match WasmScreenCell in VTermWasm.kt. + * + * offset size field + * 0 4 chars[0] (uint32, primary Unicode codepoint) + * 4 4 chars[1] (uint32, first combining char or 0) + * 8 4 chars[2] + * 12 4 chars[3] + * 16 4 chars[4] + * 20 4 chars[5] + * 24 1 fgRed + * 25 1 fgGreen + * 26 1 fgBlue + * 27 1 bgRed + * 28 1 bgGreen + * 29 1 bgBlue + * 30 1 attrs (bit 0=bold, 1=italic, 2=reverse, 3=strike, 4=blink) + * 31 1 underline (0-4) + * 32 1 width (1 or 2) + * 33 3 padding (alignment) + * total = 36 bytes + * ------------------------------------------------------------------------- */ +#define PACKED_CELL_SIZE 36 + +typedef struct { + uint32_t chars[VTERM_MAX_CHARS_PER_CELL]; + uint8_t fgRed, fgGreen, fgBlue; + uint8_t bgRed, bgGreen, bgBlue; + uint8_t attrs; + uint8_t underline; + uint8_t width; + uint8_t _pad[3]; +} PackedCell; + +/* ------------------------------------------------------------------------- + * Global state (single terminal instance per Wasm module instance) + * ------------------------------------------------------------------------- */ +static VTerm* g_vt = NULL; +static VTermScreen* g_vts = NULL; + +/* OSC fragment accumulation */ +static char g_osc_buf[4096]; +static int g_osc_len = 0; +static int g_osc_cmd = -1; +static int g_osc_start_row = 0; +static int g_osc_start_col = 0; + +/* Selection (OSC 52) accumulation */ +static char g_sel_buf[8192]; + +/* ------------------------------------------------------------------------- + * Color resolution helper + * ------------------------------------------------------------------------- */ +static void resolve_color(VTerm* vt, VTermColor c, uint8_t* r, uint8_t* g, uint8_t* b) { + if (VTERM_COLOR_IS_INDEXED(&c)) { + VTermColor resolved; + vterm_state_get_palette_color(vterm_obtain_state(vt), c.indexed.idx, &resolved); + *r = resolved.rgb.red; + *g = resolved.rgb.green; + *b = resolved.rgb.blue; + } else if (VTERM_COLOR_IS_RGB(&c)) { + *r = c.rgb.red; + *g = c.rgb.green; + *b = c.rgb.blue; + } else if (VTERM_COLOR_IS_DEFAULT_FG(&c)) { + VTermColor fg, bg; + vterm_state_get_default_colors(vterm_obtain_state(vt), &fg, &bg); + *r = fg.rgb.red; + *g = fg.rgb.green; + *b = fg.rgb.blue; + } else if (VTERM_COLOR_IS_DEFAULT_BG(&c)) { + VTermColor fg, bg; + vterm_state_get_default_colors(vterm_obtain_state(vt), &fg, &bg); + *r = bg.rgb.red; + *g = bg.rgb.green; + *b = bg.rgb.blue; + } else { + *r = *g = *b = 128; + } +} + +/* ------------------------------------------------------------------------- + * libvterm screen callbacks + * ------------------------------------------------------------------------- */ +static int screen_damage(VTermRect rect, void* user) { + return cb_damage(rect.start_row, rect.end_row, rect.start_col, rect.end_col); +} + +static int screen_moverect(VTermRect dest, VTermRect src, void* user) { + return cb_moverect(dest.start_row, dest.end_row, dest.start_col, dest.end_col, + src.start_row, src.end_row, src.start_col, src.end_col); +} + +static int screen_movecursor(VTermPos pos, VTermPos oldpos, int visible, void* user) { + return cb_movecursor(pos.row, pos.col, oldpos.row, oldpos.col, visible); +} + +/* + * settermprop type encoding (matches VTermValueType): + * 1 = bool (iVal = 0/1) + * 2 = int (iVal = value) + * 3 = string (ptr/len point into Wasm memory) + * 4 = color (iVal = (r<<16)|(g<<8)|b) + */ +static int screen_settermprop(VTermProp prop, VTermValue* val, void* user) { + VTermValueType type = vterm_get_prop_type(prop); + switch (type) { + case VTERM_VALUETYPE_BOOL: + return cb_settermprop((int)prop, 1, val->boolean ? 1 : 0, 0, 0); + case VTERM_VALUETYPE_INT: + return cb_settermprop((int)prop, 2, val->number, 0, 0); + case VTERM_VALUETYPE_STRING: + if (val->string.str) { + return cb_settermprop((int)prop, 3, 0, + (int)(uintptr_t)val->string.str, (int)val->string.len); + } + return cb_settermprop((int)prop, 3, 0, 0, 0); + case VTERM_VALUETYPE_COLOR: { + uint8_t r, g, b; + resolve_color(g_vt, val->color, &r, &g, &b); + return cb_settermprop((int)prop, 4, (r << 16) | (g << 8) | b, 0, 0); + } + default: + return 0; + } +} + +static int screen_bell(void* user) { + return cb_bell(); +} + +static int screen_sb_pushline(int cols, const VTermScreenCell* cells, void* user) { + /* Check soft-wrap: if row 1's continuation flag is set, row 0 was soft-wrapped */ + int soft_wrapped = 0; + if (g_vt) { + VTermState* state = vterm_obtain_state(g_vt); + if (state) { + const VTermLineInfo* info = vterm_state_get_lineinfo(state, 1); + if (info) soft_wrapped = info->continuation ? 1 : 0; + } + } + + /* Pack cells into a temporary stack buffer (max 256 cols) */ + PackedCell packed[256]; + int n = cols < 256 ? cols : 256; + for (int i = 0; i < n; i++) { + for (int j = 0; j < VTERM_MAX_CHARS_PER_CELL; j++) + packed[i].chars[j] = cells[i].chars[j]; + resolve_color(g_vt, cells[i].fg, + &packed[i].fgRed, &packed[i].fgGreen, &packed[i].fgBlue); + resolve_color(g_vt, cells[i].bg, + &packed[i].bgRed, &packed[i].bgGreen, &packed[i].bgBlue); + packed[i].attrs = (cells[i].attrs.bold ? 0x01 : 0) + | (cells[i].attrs.italic ? 0x02 : 0) + | (cells[i].attrs.reverse ? 0x04 : 0) + | (cells[i].attrs.strike ? 0x08 : 0) + | (cells[i].attrs.blink ? 0x10 : 0); + packed[i].underline = (uint8_t)cells[i].attrs.underline; + packed[i].width = (uint8_t)cells[i].width; + packed[i]._pad[0] = packed[i]._pad[1] = packed[i]._pad[2] = 0; + } + return cb_sb_pushline(n, (int)(uintptr_t)packed, soft_wrapped); +} + +static int screen_sb_popline(int cols, VTermScreenCell* cells, void* user) { + PackedCell packed[256]; + int n = cols < 256 ? cols : 256; + int result = cb_sb_popline(n, (int)(uintptr_t)packed); + if (!result) return 0; + + for (int i = 0; i < n; i++) { + for (int j = 0; j < VTERM_MAX_CHARS_PER_CELL; j++) + cells[i].chars[j] = packed[i].chars[j]; + vterm_color_rgb(&cells[i].fg, + packed[i].fgRed, packed[i].fgGreen, packed[i].fgBlue); + vterm_color_rgb(&cells[i].bg, + packed[i].bgRed, packed[i].bgGreen, packed[i].bgBlue); + cells[i].attrs.bold = (packed[i].attrs & 0x01) ? 1 : 0; + cells[i].attrs.italic = (packed[i].attrs & 0x02) ? 1 : 0; + cells[i].attrs.reverse = (packed[i].attrs & 0x04) ? 1 : 0; + cells[i].attrs.strike = (packed[i].attrs & 0x08) ? 1 : 0; + cells[i].attrs.blink = (packed[i].attrs & 0x10) ? 1 : 0; + cells[i].attrs.underline = packed[i].underline; + cells[i].width = packed[i].width; + } + return 1; +} + +static void term_output(const char* s, size_t len, void* user) { + cb_output((int)(uintptr_t)s, (int)len); +} + +/* ------------------------------------------------------------------------- + * OSC fallback + * ------------------------------------------------------------------------- */ +static int osc_fallback(int command, VTermStringFragment frag, void* user) { + if (frag.initial) { + g_osc_len = 0; + g_osc_cmd = command; + if (command == 8) { + VTermPos pos; + vterm_state_get_cursorpos(vterm_obtain_state(g_vt), &pos); + g_osc_start_row = pos.row; + g_osc_start_col = pos.col; + } + } + if (frag.len > 0) { + int space = (int)sizeof(g_osc_buf) - g_osc_len - 1; + int copy = (int)frag.len < space ? (int)frag.len : space; + memcpy(g_osc_buf + g_osc_len, frag.str, copy); + g_osc_len += copy; + } + if (frag.final) { + int row, col; + if (g_osc_cmd == 8) { + row = g_osc_start_row; + col = g_osc_start_col; + } else { + VTermPos pos; + vterm_state_get_cursorpos(vterm_obtain_state(g_vt), &pos); + row = pos.row; + col = pos.col; + } + int result = cb_osc(g_osc_cmd, (int)(uintptr_t)g_osc_buf, g_osc_len, row, col); + g_osc_len = 0; + g_osc_cmd = -1; + return result; + } + return 1; +} + +/* ------------------------------------------------------------------------- + * OSC 52 selection set + * ------------------------------------------------------------------------- */ +static int selection_set(VTermSelectionMask mask, VTermStringFragment frag, void* user) { + static char sel_acc[8192]; + static int sel_len = 0; + + if (frag.initial) sel_len = 0; + + if (frag.len > 0) { + int space = (int)sizeof(sel_acc) - sel_len - 1; + int copy = (int)frag.len < space ? (int)frag.len : space; + memcpy(sel_acc + sel_len, frag.str, copy); + sel_len += copy; + } + + if (frag.final && sel_len > 0) { + /* Prefix "c;" to match existing TerminalCallbacks convention */ + char payload[8196]; + memcpy(payload, "c;", 2); + memcpy(payload + 2, sel_acc, sel_len); + cb_osc(52, (int)(uintptr_t)payload, sel_len + 2, 0, 0); + sel_len = 0; + } + return 1; +} + +static int selection_query(VTermSelectionMask mask, void* user) { + return 0; +} + +/* ------------------------------------------------------------------------- + * Exported API (called from Java via Chicory) + * ------------------------------------------------------------------------- */ + +__attribute__((export_name("vterm_wasm_init"))) +int vterm_wasm_init(int rows, int cols) { + if (g_vt) { + vterm_free(g_vt); + g_vt = NULL; + g_vts = NULL; + } + + g_vt = vterm_new(rows, cols); + if (!g_vt) return -1; + + vterm_set_utf8(g_vt, 1); + vterm_output_set_callback(g_vt, term_output, NULL); + + g_vts = vterm_obtain_screen(g_vt); + vterm_screen_enable_altscreen(g_vts, 1); + + static VTermScreenCallbacks scb = { + .damage = screen_damage, + .moverect = screen_moverect, + .movecursor = screen_movecursor, + .settermprop = screen_settermprop, + .bell = screen_bell, + .resize = NULL, + .sb_pushline = screen_sb_pushline, + .sb_popline = screen_sb_popline, + .sb_clear = NULL, + }; + vterm_screen_set_callbacks(g_vts, &scb, NULL); + + VTermState* state = vterm_obtain_state(g_vt); + static VTermStateFallbacks fb = { + .osc = osc_fallback, + }; + vterm_state_set_unrecognised_fallbacks(state, &fb, NULL); + + static VTermSelectionCallbacks selcb = { + .set = selection_set, + .query = selection_query, + }; + vterm_state_set_selection_callbacks(state, &selcb, NULL, g_sel_buf, sizeof(g_sel_buf)); + + vterm_screen_set_damage_merge(g_vts, VTERM_DAMAGE_SCROLL); + vterm_screen_reset(g_vts, 1); + return 0; +} + +__attribute__((export_name("vterm_wasm_free"))) +void vterm_wasm_free(void) { + if (g_vt) { + vterm_free(g_vt); + g_vt = NULL; + g_vts = NULL; + } +} + +__attribute__((export_name("vterm_wasm_write_input"))) +int vterm_wasm_write_input(int ptr, int len) { + if (!g_vt) return -1; + size_t written = vterm_input_write(g_vt, (const char*)(uintptr_t)ptr, (size_t)len); + vterm_screen_flush_damage(g_vts); + return (int)written; +} + +__attribute__((export_name("vterm_wasm_resize"))) +int vterm_wasm_resize(int rows, int cols) { + if (!g_vt) return -1; + vterm_set_size(g_vt, rows, cols); + vterm_screen_flush_damage(g_vts); + return 0; +} + +__attribute__((export_name("vterm_wasm_dispatch_key"))) +int vterm_wasm_dispatch_key(int modifiers, int key) { + if (!g_vt) return 0; + VTermModifier mod = VTERM_MOD_NONE; + if (modifiers & 1) mod |= VTERM_MOD_SHIFT; + if (modifiers & 2) mod |= VTERM_MOD_ALT; + if (modifiers & 4) mod |= VTERM_MOD_CTRL; + vterm_keyboard_key(g_vt, (VTermKey)key, mod); + return 1; +} + +__attribute__((export_name("vterm_wasm_dispatch_char"))) +int vterm_wasm_dispatch_char(int modifiers, int codepoint) { + if (!g_vt) return 0; + VTermModifier mod = VTERM_MOD_NONE; + if (modifiers & 1) mod |= VTERM_MOD_SHIFT; + if (modifiers & 2) mod |= VTERM_MOD_ALT; + if (modifiers & 4) mod |= VTERM_MOD_CTRL; + vterm_keyboard_unichar(g_vt, codepoint, mod); + return 1; +} + +/* + * Get cell run starting at (row, col). + * Fills a PackedCell array at outPtr (caller must allocate cols * PACKED_CELL_SIZE bytes). + * Returns number of cells in the run (cells with identical style). + */ +__attribute__((export_name("vterm_wasm_get_cell_run"))) +int vterm_wasm_get_cell_run(int row, int col, int outPtr) { + if (!g_vts) return 0; + + int rows, cols; + vterm_get_size(g_vt, &rows, &cols); + if (row < 0 || row >= rows || col < 0 || col >= cols) return 0; + + VTermPos firstPos = { row, col }; + VTermScreenCell firstCell; + vterm_screen_get_cell(g_vts, firstPos, &firstCell); + + PackedCell* out = (PackedCell*)(uintptr_t)outPtr; + int runLen = 0; + + for (int c = col; c < cols && runLen < cols; c++) { + VTermPos pos = { row, c }; + VTermScreenCell cell; + vterm_screen_get_cell(g_vts, pos, &cell); + + if (c > col) { + /* Stop run if style differs */ + if (memcmp(&firstCell.fg, &cell.fg, sizeof(VTermColor)) != 0 || + memcmp(&firstCell.bg, &cell.bg, sizeof(VTermColor)) != 0 || + firstCell.attrs.bold != cell.attrs.bold || + firstCell.attrs.underline != cell.attrs.underline || + firstCell.attrs.italic != cell.attrs.italic || + firstCell.attrs.blink != cell.attrs.blink || + firstCell.attrs.reverse != cell.attrs.reverse || + firstCell.attrs.strike != cell.attrs.strike || + firstCell.attrs.font != cell.attrs.font || + firstCell.attrs.dwl != cell.attrs.dwl || + firstCell.attrs.dhl != cell.attrs.dhl) { + break; + } + } + + for (int j = 0; j < VTERM_MAX_CHARS_PER_CELL; j++) + out[runLen].chars[j] = cell.chars[j]; + resolve_color(g_vt, cell.fg, + &out[runLen].fgRed, &out[runLen].fgGreen, &out[runLen].fgBlue); + resolve_color(g_vt, cell.bg, + &out[runLen].bgRed, &out[runLen].bgGreen, &out[runLen].bgBlue); + out[runLen].attrs = (cell.attrs.bold ? 0x01 : 0) + | (cell.attrs.italic ? 0x02 : 0) + | (cell.attrs.reverse ? 0x04 : 0) + | (cell.attrs.strike ? 0x08 : 0) + | (cell.attrs.blink ? 0x10 : 0); + out[runLen].underline = (uint8_t)cell.attrs.underline; + out[runLen].width = (uint8_t)cell.width; + out[runLen]._pad[0] = out[runLen]._pad[1] = out[runLen]._pad[2] = 0; + runLen++; + + if (cell.width == 2) c++; + } + return runLen; +} + +__attribute__((export_name("vterm_wasm_set_palette_colors"))) +int vterm_wasm_set_palette_colors(int ptr, int count) { + if (!g_vt) return -1; + const uint32_t* colors = (const uint32_t*)(uintptr_t)ptr; + VTermState* state = vterm_obtain_state(g_vt); + int n = count < 16 ? count : 16; + for (int i = 0; i < n; i++) { + VTermColor c; + vterm_color_rgb(&c, + (colors[i] >> 16) & 0xFF, + (colors[i] >> 8) & 0xFF, + colors[i] & 0xFF); + vterm_state_set_palette_color(state, i, &c); + } + return n; +} + +__attribute__((export_name("vterm_wasm_set_default_colors"))) +int vterm_wasm_set_default_colors(int fg, int bg) { + if (!g_vt) return -1; + VTermScreen* screen = vterm_obtain_screen(g_vt); + VTermColor vtFg, vtBg; + vterm_color_rgb(&vtFg, (fg >> 16) & 0xFF, (fg >> 8) & 0xFF, fg & 0xFF); + vterm_color_rgb(&vtBg, (bg >> 16) & 0xFF, (bg >> 8) & 0xFF, bg & 0xFF); + vterm_screen_set_default_colors(screen, &vtFg, &vtBg); + return 0; +} + +__attribute__((export_name("vterm_wasm_get_line_continuation"))) +int vterm_wasm_get_line_continuation(int row) { + if (!g_vt) return 0; + int rows, cols; + vterm_get_size(g_vt, &rows, &cols); + if (row < 0 || row >= rows) return 0; + VTermState* state = vterm_obtain_state(g_vt); + const VTermLineInfo* info = vterm_state_get_lineinfo(state, row); + return (info && info->continuation) ? 1 : 0; +} + +__attribute__((export_name("vterm_wasm_set_bold_highbright"))) +int vterm_wasm_set_bold_highbright(int enabled) { + if (!g_vt) return -1; + vterm_state_set_bold_highbright(vterm_obtain_state(g_vt), enabled); + return 0; +} + +/* Expose linear memory so Java can read string data by pointer */ +__attribute__((export_name("vterm_wasm_memory_base"))) +int vterm_wasm_memory_base(void) { + /* Returns 0; caller uses instance.memory() directly */ + return 0; +} + +/* Allocate/free helpers so Java can write input data into Wasm memory */ +__attribute__((export_name("vterm_wasm_alloc"))) +int vterm_wasm_alloc(int size) { + return (int)(uintptr_t)malloc((size_t)size); +} + +__attribute__((export_name("vterm_wasm_dealloc"))) +void vterm_wasm_dealloc(int ptr) { + free((void*)(uintptr_t)ptr); +} + +/* Out-buffer for get_cell_run: allocated once, reused each call. + * Returns pointer to a buffer large enough for 256 PackedCells. */ +static PackedCell g_cell_run_buf[256]; + +__attribute__((export_name("vterm_wasm_cell_run_buf"))) +int vterm_wasm_cell_run_buf(void) { + return (int)(uintptr_t)g_cell_run_buf; +} + +/* Out-buffer for get_all_rows: rows * cols PackedCells, max 300*300. */ +static PackedCell g_screen_buf[300 * 300]; + +__attribute__((export_name("vterm_wasm_screen_buf"))) +int vterm_wasm_screen_buf(void) { + return (int)(uintptr_t)g_screen_buf; +} + +/* + * Fill all cells for a single row into outPtr (caller must allocate cols * PACKED_CELL_SIZE bytes). + * Returns cols, or 0 on error. + * Lets Java fetch an entire row in one Wasm call, avoiding per-run dispatch overhead. + */ +/* + * Fill all cells for every row into outPtr in row-major order. + * outPtr must point to rows*cols*PACKED_CELL_SIZE bytes (use vterm_wasm_screen_buf). + * Returns rows*cols, or 0 on error. + */ +__attribute__((export_name("vterm_wasm_get_all_rows"))) +int vterm_wasm_get_all_rows(int outPtr) { + if (!g_vts) return 0; + int rows, cols; + vterm_get_size(g_vt, &rows, &cols); + + PackedCell* out = (PackedCell*)(uintptr_t)outPtr; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + VTermPos pos = { r, c }; + VTermScreenCell cell; + vterm_screen_get_cell(g_vts, pos, &cell); + int idx = r * cols + c; + for (int j = 0; j < VTERM_MAX_CHARS_PER_CELL; j++) + out[idx].chars[j] = cell.chars[j]; + resolve_color(g_vt, cell.fg, + &out[idx].fgRed, &out[idx].fgGreen, &out[idx].fgBlue); + resolve_color(g_vt, cell.bg, + &out[idx].bgRed, &out[idx].bgGreen, &out[idx].bgBlue); + out[idx].attrs = (cell.attrs.bold ? 0x01 : 0) + | (cell.attrs.italic ? 0x02 : 0) + | (cell.attrs.reverse ? 0x04 : 0) + | (cell.attrs.strike ? 0x08 : 0) + | (cell.attrs.blink ? 0x10 : 0); + out[idx].underline = (uint8_t)cell.attrs.underline; + out[idx].width = (uint8_t)cell.width; + out[idx]._pad[0] = out[idx]._pad[1] = out[idx]._pad[2] = 0; + } + } + return rows * cols; +} + +__attribute__((export_name("vterm_wasm_get_row"))) +int vterm_wasm_get_row(int row, int outPtr) { + if (!g_vts) return 0; + int rows, cols; + vterm_get_size(g_vt, &rows, &cols); + if (row < 0 || row >= rows) return 0; + + PackedCell* out = (PackedCell*)(uintptr_t)outPtr; + for (int c = 0; c < cols; c++) { + VTermPos pos = { row, c }; + VTermScreenCell cell; + vterm_screen_get_cell(g_vts, pos, &cell); + + for (int j = 0; j < VTERM_MAX_CHARS_PER_CELL; j++) + out[c].chars[j] = cell.chars[j]; + resolve_color(g_vt, cell.fg, + &out[c].fgRed, &out[c].fgGreen, &out[c].fgBlue); + resolve_color(g_vt, cell.bg, + &out[c].bgRed, &out[c].bgGreen, &out[c].bgBlue); + out[c].attrs = (cell.attrs.bold ? 0x01 : 0) + | (cell.attrs.italic ? 0x02 : 0) + | (cell.attrs.reverse ? 0x04 : 0) + | (cell.attrs.strike ? 0x08 : 0) + | (cell.attrs.blink ? 0x10 : 0); + out[c].underline = (uint8_t)cell.attrs.underline; + out[c].width = (uint8_t)cell.width; + out[c]._pad[0] = out[c]._pad[1] = out[c]._pad[2] = 0; + } + return cols; +} diff --git a/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/ShadowTerminalNative.kt b/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/ShadowTerminalNative.kt new file mode 100644 index 00000000..2de7fb00 --- /dev/null +++ b/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/ShadowTerminalNative.kt @@ -0,0 +1,213 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal.wasm + +import org.connectbot.terminal.CellRun +import org.connectbot.terminal.CursorPosition +import org.connectbot.terminal.ScreenCell +import org.connectbot.terminal.TermRect +import org.connectbot.terminal.TerminalCallbacks +import org.connectbot.terminal.TerminalNative +import org.connectbot.terminal.TerminalProperty +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.shadow.api.Shadow + +/** + * Robolectric shadow that replaces [TerminalNative] with [TerminalWasm] for host JVM tests. + * + * Add to your test configuration: + * ```kotlin + * @Config(shadows = [ShadowTerminalNative::class]) + * ``` + * or globally in `robolectric.properties`: + * ``` + * shadows=org.connectbot.terminal.wasm.ShadowTerminalNative + * ``` + */ +@Implements(TerminalNative::class) +class ShadowTerminalNative { + @RealObject + private lateinit var realNative: TerminalNative + + private lateinit var wasm: TerminalWasm + private var rows = 24 + private var cols = 80 + + private inner class BridgeCallbacks( + private val callbacks: TerminalCallbacks, + ) : WasmCallbacks { + override fun damage(startRow: Int, endRow: Int, startCol: Int, endCol: Int): Int = callbacks.damage(startRow, endRow, startCol, endCol) + + override fun moverect( + dstStartRow: Int, + dstEndRow: Int, + dstStartCol: Int, + dstEndCol: Int, + srcStartRow: Int, + srcEndRow: Int, + srcStartCol: Int, + srcEndCol: Int, + ): Int = callbacks.moverect( + TermRect(dstStartRow, dstEndRow, dstStartCol, dstEndCol), + TermRect(srcStartRow, srcEndRow, srcStartCol, srcEndCol), + ) + + override fun moveCursor(row: Int, col: Int, oldRow: Int, oldCol: Int, visible: Boolean): Int = callbacks.moveCursor(CursorPosition(row, col), CursorPosition(oldRow, oldCol), visible) + + override fun setTermProp(prop: Int, type: Int, iVal: Int, str: String?): Int { + val value: TerminalProperty = when (type) { + 1 -> TerminalProperty.BoolValue(iVal != 0) + + 2 -> TerminalProperty.IntValue(iVal) + + 3 -> TerminalProperty.StringValue(str ?: "") + + 4 -> TerminalProperty.ColorValue( + (iVal shr 16) and 0xFF, + (iVal shr 8) and 0xFF, + iVal and 0xFF, + ) + + else -> return 0 + } + return callbacks.setTermProp(prop, value) + } + + override fun bell(): Int = callbacks.bell() + + override fun pushScrollbackLine(cells: List, softWrapped: Boolean): Int { + val screenCells = Array(cells.size) { i -> + val c = cells[i] + val cp = c.chars.firstOrNull { it != 0 } ?: ' '.code + val (char, combining) = if (cp > 0xFFFF) { + val high = Character.highSurrogate(cp) + val low = Character.lowSurrogate(cp) + high to listOf(low) + } else { + cp.toChar() to emptyList() + } + ScreenCell( + char = char, + combiningChars = combining, + fgRed = c.fgRed, + fgGreen = c.fgGreen, + fgBlue = c.fgBlue, + bgRed = c.bgRed, + bgGreen = c.bgGreen, + bgBlue = c.bgBlue, + bold = c.bold, + italic = c.italic, + underline = c.underline, + reverse = c.reverse, + strike = c.strike, + width = c.width, + ) + } + return callbacks.pushScrollbackLine(cells.size, screenCells, softWrapped) + } + + override fun popScrollbackLine(cols: Int): List? { + val cells = Array(cols) { + ScreenCell(char = ' ', fgRed = 0, fgGreen = 0, fgBlue = 0, bgRed = 0, bgGreen = 0, bgBlue = 0) + } + val result = callbacks.popScrollbackLine(cols, cells) + if (result == 0) return null + return cells.map { c -> + val cp = if (c.combiningChars.isNotEmpty() && c.combiningChars[0].isLowSurrogate()) { + Character.toCodePoint(c.char, c.combiningChars[0]) + } else { + c.char.code + } + WasmScreenCell( + chars = intArrayOf(cp, 0, 0, 0, 0, 0), + fgRed = c.fgRed, + fgGreen = c.fgGreen, + fgBlue = c.fgBlue, + bgRed = c.bgRed, + bgGreen = c.bgGreen, + bgBlue = c.bgBlue, + bold = c.bold, + italic = c.italic, + underline = c.underline, + reverse = c.reverse, + strike = c.strike, + width = c.width, + ) + } + } + + override fun onKeyboardOutput(data: ByteArray) { + callbacks.onKeyboardInput(data) + } + + override fun onOscSequence(command: Int, payload: String, cursorRow: Int, cursorCol: Int): Int = callbacks.onOscSequence(command, payload, cursorRow, cursorCol) + } + + @Implementation + @Suppress("ktlint:standard:function-naming") + fun __constructor__(callbacks: TerminalCallbacks) { + wasm = TerminalWasm(rows, cols, BridgeCallbacks(callbacks)) + } + + @Implementation + fun writeInput(data: ByteArray, offset: Int, length: Int): Int = wasm.writeInput(data, offset, length) + + @Implementation + fun resize(rows: Int, cols: Int): Int { + this.rows = rows + this.cols = cols + return wasm.resize(rows, cols) + } + + @Implementation + fun dispatchKey(modifiers: Int, key: Int): Boolean = wasm.dispatchKey(modifiers, key) + + @Implementation + fun dispatchCharacter(modifiers: Int, codepoint: Int): Boolean = wasm.dispatchCharacter(modifiers, codepoint) + + @Implementation + fun getCellRun(row: Int, col: Int, run: CellRun): Int = wasm.getCellRun(row, col, run) + + @Implementation + fun setPaletteColors(colors: IntArray, count: Int): Int = wasm.setPaletteColors(colors, count) + + @Implementation + fun setDefaultColors(foreground: Int, background: Int): Int = wasm.setDefaultColors(foreground, background) + + @Implementation + fun getLineContinuation(row: Int): Boolean = wasm.getLineContinuation(row) + + @Implementation + fun setBoldHighbright(enabled: Boolean): Int = wasm.setBoldHighbright(enabled) + + @Implementation + fun close() = wasm.close() + + companion object { + @Implementation + @JvmStatic + @Suppress("ktlint:standard:function-naming") + fun __staticInitializer__() { + // Suppress System.loadLibrary("jni_cb_term") — WASM backend needs no native library. + } + + @JvmStatic + fun getShadow(native: TerminalNative): ShadowTerminalNative = Shadow.extract(native) + } +} diff --git a/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/TerminalWasm.kt b/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/TerminalWasm.kt new file mode 100644 index 00000000..5cddc2c8 --- /dev/null +++ b/lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/TerminalWasm.kt @@ -0,0 +1,578 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal.wasm + +import com.dylibso.chicory.runtime.ByteArrayMemory +import com.dylibso.chicory.runtime.HostFunction +import com.dylibso.chicory.runtime.Instance +import com.dylibso.chicory.wasi.WasiPreview1 +import com.dylibso.chicory.wasm.types.FunctionType +import com.dylibso.chicory.wasm.types.ValType +import org.connectbot.terminal.CellRun +import org.connectbot.terminal.TerminalBackend +import org.connectbot.terminal.wasm.generated.LibVterm + +/** + * Callbacks invoked by [TerminalWasm] when terminal state changes. + */ +interface WasmCallbacks { + fun damage( + startRow: Int, + endRow: Int, + startCol: Int, + endCol: Int, + ): Int + + fun moverect( + dstStartRow: Int, + dstEndRow: Int, + dstStartCol: Int, + dstEndCol: Int, + srcStartRow: Int, + srcEndRow: Int, + srcStartCol: Int, + srcEndCol: Int, + ): Int + + fun moveCursor( + row: Int, + col: Int, + oldRow: Int, + oldCol: Int, + visible: Boolean, + ): Int + + /** + * @param type 1=bool, 2=int, 3=string, 4=color(rgb packed as 0xRRGGBB) + * @param iVal bool/int/color value (type 1, 2, 4) + * @param str string value (type 3), null for other types + */ + fun setTermProp( + prop: Int, + type: Int, + iVal: Int, + str: String?, + ): Int + + fun bell(): Int + + fun pushScrollbackLine( + cells: List, + softWrapped: Boolean, + ): Int + + fun popScrollbackLine(cols: Int): List? + + fun onKeyboardOutput(data: ByteArray) + + fun onOscSequence( + command: Int, + payload: String, + cursorRow: Int, + cursorCol: Int, + ): Int +} + +/** + * A single terminal cell exchanged through the Wasm boundary. + * + * Layout (36 bytes, matches PackedCell in vterm_wasm.c): + * 0-23 chars[0..5] uint32 each (Unicode codepoints, 0 = absent) + * 24 fgRed + * 25 fgGreen + * 26 fgBlue + * 27 bgRed + * 28 bgGreen + * 29 bgBlue + * 30 attrs bit0=bold, bit1=italic, bit2=reverse, bit3=strike, bit4=blink + * 31 underline (0-4) + * 32 width (1 or 2) + * 33-35 padding + */ +data class WasmScreenCell( + val chars: IntArray, + val fgRed: Int, + val fgGreen: Int, + val fgBlue: Int, + val bgRed: Int, + val bgGreen: Int, + val bgBlue: Int, + val bold: Boolean = false, + val italic: Boolean = false, + val reverse: Boolean = false, + val strike: Boolean = false, + val blink: Boolean = false, + val underline: Int = 0, + val width: Int = 1, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WasmScreenCell) return false + return chars.contentEquals(other.chars) && + fgRed == other.fgRed && fgGreen == other.fgGreen && fgBlue == other.fgBlue && + bgRed == other.bgRed && bgGreen == other.bgGreen && bgBlue == other.bgBlue && + bold == other.bold && italic == other.italic && reverse == other.reverse && + strike == other.strike && blink == other.blink && + underline == other.underline && width == other.width + } + + override fun hashCode(): Int = chars.contentHashCode() +} + +private const val PACKED_CELL_SIZE = 36 +private const val MAX_CHARS_PER_CELL = 6 +private const val IMPORT_MODULE = "vterm_cb" + +class TerminalWasm( + private val rows: Int, + private val cols: Int, + private val callbacks: WasmCallbacks, +) : TerminalBackend { + private val instance: Instance = buildInstance() + private val memory get() = instance.memory() + + private val fnInit = instance.export("vterm_wasm_init") + private val fnFree = instance.export("vterm_wasm_free") + private val fnWriteInput = instance.export("vterm_wasm_write_input") + private val fnResize = instance.export("vterm_wasm_resize") + private val fnDispatchKey = instance.export("vterm_wasm_dispatch_key") + private val fnDispatchChar = instance.export("vterm_wasm_dispatch_char") + private val fnGetCellRun = instance.export("vterm_wasm_get_cell_run") + private val fnSetPalette = instance.export("vterm_wasm_set_palette_colors") + private val fnSetDefaultColors = instance.export("vterm_wasm_set_default_colors") + private val fnGetLineCont = instance.export("vterm_wasm_get_line_continuation") + private val fnSetBoldHB = instance.export("vterm_wasm_set_bold_highbright") + private val fnAlloc = instance.export("vterm_wasm_alloc") + private val fnDealloc = instance.export("vterm_wasm_dealloc") + private val fnCellRunBuf = instance.export("vterm_wasm_cell_run_buf") + private val fnGetRow = instance.export("vterm_wasm_get_row") + private val fnGetAllRows = instance.export("vterm_wasm_get_all_rows") + private val fnScreenBuf = instance.export("vterm_wasm_screen_buf") + + private val cellRunBufPtr: Int = fnCellRunBuf.apply().first().toInt() + private val screenBufPtr: Int = fnScreenBuf.apply().first().toInt() + + init { + val rc = fnInit.apply(rows.toLong(), cols.toLong()).first().toInt() + check(rc == 0) { "vterm_wasm_init failed: $rc" } + } + + override fun writeInput( + data: ByteArray, + offset: Int, + length: Int, + ): Int { + val ptr = wasmAlloc(length) + try { + memory.write(ptr, data, offset, length) + return fnWriteInput.apply(ptr.toLong(), length.toLong()).first().toInt() + } finally { + wasmFree(ptr) + } + } + + override fun resize( + rows: Int, + cols: Int, + ): Int = fnResize.apply(rows.toLong(), cols.toLong()).first().toInt() + + override fun dispatchKey( + modifiers: Int, + key: Int, + ): Boolean = fnDispatchKey.apply(modifiers.toLong(), key.toLong()).first().toInt() != 0 + + override fun dispatchCharacter( + modifiers: Int, + codepoint: Int, + ): Boolean = fnDispatchChar.apply(modifiers.toLong(), codepoint.toLong()).first().toInt() != 0 + + override fun getCellRun( + row: Int, + col: Int, + run: CellRun, + ): Int { + val count = + fnGetCellRun + .apply( + row.toLong(), + col.toLong(), + cellRunBufPtr.toLong(), + ).first() + .toInt() + if (count <= 0) return 0 + fillCellRun(run, cellRunBufPtr, count) + return count + } + + override fun scanRow( + row: Int, + cols: Int, + run: CellRun, + block: (CellRun) -> Unit, + ) { + val totalBytes = cols * PACKED_CELL_SIZE + fnGetRow.apply(row.toLong(), cellRunBufPtr.toLong()) + val buf = memory.readBytes(cellRunBufPtr, totalBytes) + var col = 0 + while (col < cols) { + val startCol = col + val o0 = col * PACKED_CELL_SIZE + val refAttrs = buf[o0 + 30].toInt() and 0xFF + val refFgR = buf[o0 + 24].toInt() and 0xFF + val refFgG = buf[o0 + 25].toInt() and 0xFF + val refFgB = buf[o0 + 26].toInt() and 0xFF + val refBgR = buf[o0 + 27].toInt() and 0xFF + val refBgG = buf[o0 + 28].toInt() and 0xFF + val refBgB = buf[o0 + 29].toInt() and 0xFF + val refUnder = buf[o0 + 31].toInt() and 0xFF + var runEnd = col + 1 + while (runEnd < cols) { + val o = runEnd * PACKED_CELL_SIZE + if ((buf[o + 30].toInt() and 0xFF) != refAttrs || + (buf[o + 24].toInt() and 0xFF) != refFgR || + (buf[o + 25].toInt() and 0xFF) != refFgG || + (buf[o + 26].toInt() and 0xFF) != refFgB || + (buf[o + 27].toInt() and 0xFF) != refBgR || + (buf[o + 28].toInt() and 0xFF) != refBgG || + (buf[o + 29].toInt() and 0xFF) != refBgB || + (buf[o + 31].toInt() and 0xFF) != refUnder + ) { + break + } + runEnd++ + } + val count = runEnd - startCol + fillCellRunFromBuf(run, buf, startCol, count) + block(run) + col = runEnd + } + } + + override fun scanAllRows( + rows: Int, + cols: Int, + run: CellRun, + rowStart: (Int) -> Unit, + block: (CellRun) -> Unit, + ) { + val totalBytes = rows * cols * PACKED_CELL_SIZE + fnGetAllRows.apply(screenBufPtr.toLong()) + val buf = memory.readBytes(screenBufPtr, totalBytes) + for (row in 0 until rows) { + rowStart(row) + var col = 0 + while (col < cols) { + val startCol = col + val o0 = (row * cols + col) * PACKED_CELL_SIZE + val refAttrs = buf[o0 + 30].toInt() and 0xFF + val refFgR = buf[o0 + 24].toInt() and 0xFF + val refFgG = buf[o0 + 25].toInt() and 0xFF + val refFgB = buf[o0 + 26].toInt() and 0xFF + val refBgR = buf[o0 + 27].toInt() and 0xFF + val refBgG = buf[o0 + 28].toInt() and 0xFF + val refBgB = buf[o0 + 29].toInt() and 0xFF + val refUnder = buf[o0 + 31].toInt() and 0xFF + var runEnd = col + 1 + while (runEnd < cols) { + val o = (row * cols + runEnd) * PACKED_CELL_SIZE + if ((buf[o + 30].toInt() and 0xFF) != refAttrs || + (buf[o + 24].toInt() and 0xFF) != refFgR || + (buf[o + 25].toInt() and 0xFF) != refFgG || + (buf[o + 26].toInt() and 0xFF) != refFgB || + (buf[o + 27].toInt() and 0xFF) != refBgR || + (buf[o + 28].toInt() and 0xFF) != refBgG || + (buf[o + 29].toInt() and 0xFF) != refBgB || + (buf[o + 31].toInt() and 0xFF) != refUnder + ) { + break + } + runEnd++ + } + fillCellRunFromBuf(run, buf, row * cols + startCol, runEnd - startCol) + block(run) + col = runEnd + } + } + } + + override fun setPaletteColors( + colors: IntArray, + count: Int, + ): Int { + val bytes = count * 4 + val ptr = wasmAlloc(bytes) + try { + for (i in 0 until count) memory.writeI32(ptr + i * 4, colors[i]) + return fnSetPalette.apply(ptr.toLong(), count.toLong()).first().toInt() + } finally { + wasmFree(ptr) + } + } + + override fun setDefaultColors( + foreground: Int, + background: Int, + ): Int = fnSetDefaultColors.apply(foreground.toLong(), background.toLong()).first().toInt() + + override fun getLineContinuation(row: Int): Boolean = fnGetLineCont.apply(row.toLong()).first().toInt() != 0 + + override fun setBoldHighbright(enabled: Boolean): Int = fnSetBoldHB.apply(if (enabled) 1L else 0L).first().toInt() + + override fun close() { + fnFree.apply() + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private fun buildInstance(): Instance { + val wasi = WasiPreview1.builder().build() + val imports = + com.dylibso.chicory.runtime.ImportValues + .builder() + .withFunctions(buildHostFunctions() + wasi.toHostFunctions().toList()) + .build() + return Instance + .builder(LibVterm.load()) + .withMachineFactory(LibVterm::create) + .withMemoryFactory(::ByteArrayMemory) + .withImportValues(imports) + .build() + } + + private fun buildHostFunctions(): List = listOf( + hostFn("damage", listOf(I32, I32, I32, I32), listOf(I32)) { _, args -> + longArrayOf( + callbacks + .damage( + args[0].toInt(), + args[1].toInt(), + args[2].toInt(), + args[3].toInt(), + ).toLong(), + ) + }, + hostFn("moverect", listOf(I32, I32, I32, I32, I32, I32, I32, I32), listOf(I32)) { _, args -> + longArrayOf( + callbacks + .moverect( + args[0].toInt(), + args[1].toInt(), + args[2].toInt(), + args[3].toInt(), + args[4].toInt(), + args[5].toInt(), + args[6].toInt(), + args[7].toInt(), + ).toLong(), + ) + }, + hostFn("movecursor", listOf(I32, I32, I32, I32, I32), listOf(I32)) { _, args -> + longArrayOf( + callbacks + .moveCursor( + args[0].toInt(), + args[1].toInt(), + args[2].toInt(), + args[3].toInt(), + args[4].toInt() != 0, + ).toLong(), + ) + }, + hostFn("settermprop", listOf(I32, I32, I32, I32, I32), listOf(I32)) { inst, args -> + val prop = args[0].toInt() + val type = args[1].toInt() + val iVal = args[2].toInt() + val ptr = args[3].toInt() + val len = args[4].toInt() + val str = if (type == 3 && ptr != 0 && len > 0) inst.memory().readString(ptr, len) else null + longArrayOf(callbacks.setTermProp(prop, type, iVal, str).toLong()) + }, + hostFn("bell", emptyList(), listOf(I32)) { _, _ -> + longArrayOf(callbacks.bell().toLong()) + }, + hostFn("sb_pushline", listOf(I32, I32, I32), listOf(I32)) { _, args -> + val cols = args[0].toInt() + val cellsPtr = args[1].toInt() + val softWrapped = args[2].toInt() != 0 + longArrayOf(callbacks.pushScrollbackLine(readPackedCells(cellsPtr, cols), softWrapped).toLong()) + }, + hostFn("sb_popline", listOf(I32, I32), listOf(I32)) { _, args -> + val cols = args[0].toInt() + val cellsPtr = args[1].toInt() + val cells = callbacks.popScrollbackLine(cols) + if (cells == null) { + longArrayOf(0L) + } else { + writePackedCells(cellsPtr, cells, cols) + longArrayOf(1L) + } + }, + hostFn("output", listOf(I32, I32), emptyList()) { inst, args -> + val data = inst.memory().readBytes(args[0].toInt(), args[1].toInt()) + callbacks.onKeyboardOutput(data) + longArrayOf() + }, + hostFn("osc", listOf(I32, I32, I32, I32, I32), listOf(I32)) { inst, args -> + val command = args[0].toInt() + val ptr = args[1].toInt() + val len = args[2].toInt() + val cursorRow = args[3].toInt() + val cursorCol = args[4].toInt() + val payload = if (ptr != 0 && len > 0) inst.memory().readString(ptr, len) else "" + longArrayOf(callbacks.onOscSequence(command, payload, cursorRow, cursorCol).toLong()) + }, + ) + + /** Fills a [CellRun] from the packed buffer with a single bulk memory read. */ + private fun fillCellRun( + run: CellRun, + ptr: Int, + count: Int, + ) { + val buf = memory.readBytes(ptr, count * PACKED_CELL_SIZE) + fillCellRunFromBuf(run, buf, 0, count) + } + + /** Fills a [CellRun] from a pre-fetched [buf], starting at cell index [startCell]. */ + private fun fillCellRunFromBuf( + run: CellRun, + buf: ByteArray, + startCell: Int, + count: Int, + ) { + run.reset() + val base = startCell * PACKED_CELL_SIZE + val attrs = buf[base + 30].toInt() and 0xFF + run.fgRed = buf[base + 24].toInt() and 0xFF + run.fgGreen = buf[base + 25].toInt() and 0xFF + run.fgBlue = buf[base + 26].toInt() and 0xFF + run.bgRed = buf[base + 27].toInt() and 0xFF + run.bgGreen = buf[base + 28].toInt() and 0xFF + run.bgBlue = buf[base + 29].toInt() and 0xFF + run.bold = attrs and 0x01 != 0 + run.italic = attrs and 0x02 != 0 + run.reverse = attrs and 0x04 != 0 + run.strike = attrs and 0x08 != 0 + run.blink = attrs and 0x10 != 0 + run.underline = buf[base + 31].toInt() and 0xFF + + var charPos = 0 + if (run.chars.size < count * 2) run.chars = CharArray(count * 2) + for (i in 0 until count) { + val o = base + i * PACKED_CELL_SIZE + val cp = + (buf[o].toInt() and 0xFF) or + ((buf[o + 1].toInt() and 0xFF) shl 8) or + ((buf[o + 2].toInt() and 0xFF) shl 16) or + ((buf[o + 3].toInt() and 0xFF) shl 24) + if (cp == 0) { + run.chars[charPos++] = ' ' + } else if (cp > 0xFFFF) { + run.chars[charPos++] = Character.highSurrogate(cp) + run.chars[charPos++] = Character.lowSurrogate(cp) + } else { + run.chars[charPos++] = cp.toChar() + } + } + run.runLength = count + } + + private fun readPackedCells( + ptr: Int, + count: Int, + ): List = List(count) { i -> + val base = ptr + i * PACKED_CELL_SIZE + val chars = IntArray(MAX_CHARS_PER_CELL) { j -> memory.readInt(base + j * 4) } + val attrs = memory.read(base + 30).toInt() and 0xFF + WasmScreenCell( + chars = chars, + fgRed = memory.read(base + 24).toInt() and 0xFF, + fgGreen = memory.read(base + 25).toInt() and 0xFF, + fgBlue = memory.read(base + 26).toInt() and 0xFF, + bgRed = memory.read(base + 27).toInt() and 0xFF, + bgGreen = memory.read(base + 28).toInt() and 0xFF, + bgBlue = memory.read(base + 29).toInt() and 0xFF, + bold = attrs and 0x01 != 0, + italic = attrs and 0x02 != 0, + reverse = attrs and 0x04 != 0, + strike = attrs and 0x08 != 0, + blink = attrs and 0x10 != 0, + underline = memory.read(base + 31).toInt() and 0xFF, + width = if ((memory.read(base + 32).toInt() and 0xFF) == 2) 2 else 1, + ) + } + + private fun writePackedCells( + ptr: Int, + cells: List, + cols: Int, + ) { + val n = minOf(cells.size, cols) + for (i in 0 until n) { + val base = ptr + i * PACKED_CELL_SIZE + val cell = cells[i] + for (j in 0 until MAX_CHARS_PER_CELL) { + memory.writeI32(base + j * 4, if (j < cell.chars.size) cell.chars[j] else 0) + } + memory.writeByte(base + 24, cell.fgRed.toByte()) + memory.writeByte(base + 25, cell.fgGreen.toByte()) + memory.writeByte(base + 26, cell.fgBlue.toByte()) + memory.writeByte(base + 27, cell.bgRed.toByte()) + memory.writeByte(base + 28, cell.bgGreen.toByte()) + memory.writeByte(base + 29, cell.bgBlue.toByte()) + val attrs = + (if (cell.bold) 0x01 else 0) or + (if (cell.italic) 0x02 else 0) or + (if (cell.reverse) 0x04 else 0) or + (if (cell.strike) 0x08 else 0) or + (if (cell.blink) 0x10 else 0) + memory.writeByte(base + 30, attrs.toByte()) + memory.writeByte(base + 31, cell.underline.toByte()) + memory.writeByte(base + 32, cell.width.toByte()) + memory.writeByte(base + 33, 0) + memory.writeByte(base + 34, 0) + memory.writeByte(base + 35, 0) + } + for (i in n until cols) { + val base = ptr + i * PACKED_CELL_SIZE + repeat(PACKED_CELL_SIZE) { j -> memory.writeByte(base + j, 0) } + } + } + + private fun wasmAlloc(size: Int): Int = fnAlloc.apply(size.toLong()).first().toInt() + + private fun wasmFree(ptr: Int) { + fnDealloc.apply(ptr.toLong()) + } + + private companion object { + val I32 = ValType.I32 + + fun hostFn( + name: String, + params: List, + results: List, + body: (Instance, LongArray) -> LongArray, + ) = HostFunction( + IMPORT_MODULE, + name, + FunctionType.of(params, results), + ) { inst, args -> body(inst, args) } + } +} diff --git a/lib-wasm/src/main/resources/libvterm.wasm b/lib-wasm/src/main/resources/libvterm.wasm new file mode 100644 index 00000000..c78e4883 Binary files /dev/null and b/lib-wasm/src/main/resources/libvterm.wasm differ diff --git a/lib-wasm/src/test/kotlin/org/connectbot/terminal/wasm/TerminalWasmTest.kt b/lib-wasm/src/test/kotlin/org/connectbot/terminal/wasm/TerminalWasmTest.kt new file mode 100644 index 00000000..36e857d7 --- /dev/null +++ b/lib-wasm/src/test/kotlin/org/connectbot/terminal/wasm/TerminalWasmTest.kt @@ -0,0 +1,148 @@ +/* + * ConnectBot Terminal + * Copyright 2025 Kenny Root + * + * 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 + * + * http://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. + */ +package org.connectbot.terminal.wasm + +import org.connectbot.terminal.CellRun +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalWasmTest { + private fun makeCallbacks( + onDamage: (Int, Int, Int, Int) -> Int = { _, _, _, _ -> 0 }, + onOutput: (ByteArray) -> Unit = {}, + ) = object : WasmCallbacks { + override fun damage( + startRow: Int, + endRow: Int, + startCol: Int, + endCol: Int, + ) = onDamage(startRow, endRow, startCol, endCol) + + override fun moverect( + dstStartRow: Int, + dstEndRow: Int, + dstStartCol: Int, + dstEndCol: Int, + srcStartRow: Int, + srcEndRow: Int, + srcStartCol: Int, + srcEndCol: Int, + ) = 0 + + override fun moveCursor( + row: Int, + col: Int, + oldRow: Int, + oldCol: Int, + visible: Boolean, + ) = 0 + + override fun setTermProp( + prop: Int, + type: Int, + iVal: Int, + str: String?, + ) = 0 + + override fun bell() = 0 + + override fun pushScrollbackLine( + cells: List, + softWrapped: Boolean, + ) = 0 + + override fun popScrollbackLine(cols: Int): List? = null + + override fun onKeyboardOutput(data: ByteArray) = onOutput(data) + + override fun onOscSequence( + command: Int, + payload: String, + cursorRow: Int, + cursorCol: Int, + ) = 0 + } + + @Test + fun writeInputTriggersDamageCallback() { + var damaged = false + TerminalWasm( + 24, + 80, + makeCallbacks(onDamage = { _, _, _, _ -> + damaged = true + 0 + }), + ).use { + it.writeInput("Hello".toByteArray()) + } + assertTrue("damage callback must be invoked after writeInput", damaged) + } + + @Test + fun cellRunContainsWrittenText() { + TerminalWasm(24, 80, makeCallbacks()).use { term -> + term.writeInput("Hello".toByteArray()) + val run = CellRun() + val count = term.getCellRun(0, 0, run) + assertTrue("run must not be empty", count > 0) + val text = String(run.chars, 0, run.runLength) + assertTrue("rendered text must start with 'Hello'", text.startsWith("Hello")) + } + } + + @Test + fun resizeDoesNotCrash() { + TerminalWasm(24, 80, makeCallbacks()).use { + assertEquals(0, it.resize(40, 120)) + } + } + + @Test + fun dispatchKeyProducesOutput() { + val output = mutableListOf() + TerminalWasm(24, 80, makeCallbacks(onOutput = { data -> output.addAll(data.toList()) })).use { + it.dispatchKey(0, 13) + } + assertTrue("keyboard output must be non-empty after key dispatch", output.isNotEmpty()) + } + + @Test + fun oscSequenceIsDeliveredToCallback() { + var receivedCommand = -1 + var receivedPayload = "" + val cbs = + object : WasmCallbacks by makeCallbacks() { + override fun onOscSequence( + command: Int, + payload: String, + cursorRow: Int, + cursorCol: Int, + ): Int { + receivedCommand = command + receivedPayload = payload + return 1 + } + } + TerminalWasm(24, 80, cbs).use { + it.writeInput("\u001B]133;A\u001B\\".toByteArray()) + } + assertEquals(133, receivedCommand) + assertEquals("A", receivedPayload) + } +} diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 4e7fae60..09491686 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -1,4 +1,3 @@ -import com.vanniktech.maven.publish.DeploymentValidation import org.jetbrains.dokka.gradle.formats.DokkaFormatPlugin import org.jetbrains.dokka.gradle.internal.InternalDokkaGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -7,7 +6,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.compose) id("kotlin-parcelize") - alias(libs.plugins.publish) + id("termlib-publish") alias(libs.plugins.metalava) alias(libs.plugins.dokka) } @@ -26,38 +25,7 @@ abstract class DokkaMarkdownPlugin : DokkaFormatPlugin(formatName = "markdown") apply() -val hostJniDir = layout.buildDirectory.dir("host-jni") -val cppSourceDir = layout.projectDirectory.dir("src/main/cpp") - -val cmakeConfigureHost by tasks.registering(Exec::class) { - group = "build" - description = "Configure the CMake host build of jni_cb_term" - inputs.dir(cppSourceDir) - outputs.dir(hostJniDir) - commandLine( - "cmake", - "-S", - cppSourceDir.asFile.absolutePath, - "-B", - hostJniDir.get().asFile.absolutePath, - "-DCMAKE_BUILD_TYPE=Debug", - ) -} - -val cmakeBuildHost by tasks.registering(Exec::class) { - group = "build" - description = "Build libjni_cb_term for the host JVM" - dependsOn(cmakeConfigureHost) - inputs.dir(hostJniDir) - commandLine( - "cmake", - "--build", - hostJniDir.get().asFile.absolutePath, - "--target", - "jni_cb_term", - ) - outputs.dir(hostJniDir) -} +val libNativeHostJniDir = project(":lib-native").layout.buildDirectory.dir("host-jni") android { namespace = "org.connectbot.terminal" @@ -119,8 +87,8 @@ android { unitTests { isIncludeAndroidResources = true all { testTask -> - testTask.dependsOn(cmakeBuildHost) - testTask.jvmArgs("-Djava.library.path=${hostJniDir.get().asFile.absolutePath}") + testTask.dependsOn(project(":lib-native").tasks.named("cmakeBuildHost")) + testTask.jvmArgs("-Djava.library.path=${libNativeHostJniDir.get().asFile.absolutePath}") } } } @@ -133,6 +101,8 @@ kotlin { } dependencies { + implementation(project(":lib-intf")) + implementation(project(":lib-native")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) @@ -185,34 +155,11 @@ dokka { } mavenPublishing { - publishToMavenCentral(automaticRelease = true, validateDeployment = DeploymentValidation.PUBLISHED) - signAllPublications() - coordinates(groupId = "org.connectbot", artifactId = "termlib") pom { name.set("termlib") description.set("ConnectBot's terminal emulator Android Compose component using libvterm") inceptionYear.set("2025") - url.set(gitHubUrl) - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - developers { - developer { - id.set("kruton") - name.set("Kenny Root") - url.set("https://github.com/kruton/") - } - } - scm { - connection.set("scm:git:$gitHubUrl.git") - developerConnection.set("$gitHubUrl.git") - url.set(gitHubUrl) - } } } diff --git a/lib/src/main/cpp/CMakeLists.txt b/lib/src/main/cpp/CMakeLists.txt index 7d0a1380..a922ac08 100644 --- a/lib/src/main/cpp/CMakeLists.txt +++ b/lib/src/main/cpp/CMakeLists.txt @@ -2,37 +2,32 @@ cmake_minimum_required(VERSION 3.18.1) project("cb_term") -# libvterm static library -add_library(vterm STATIC - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/encoding.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/keyboard.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/mouse.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/parser.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/pen.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/screen.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/state.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/unicode.c - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/src/vterm.c -) - -target_include_directories(vterm PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/include -) +set(LIB_NATIVE_CPP "${CMAKE_CURRENT_SOURCE_DIR}/../../../../lib-native/src/main/cpp") -target_compile_definitions(vterm PRIVATE - VTERM_STATIC +add_library(vterm STATIC + ${LIB_NATIVE_CPP}/libvterm/src/encoding.c + ${LIB_NATIVE_CPP}/libvterm/src/keyboard.c + ${LIB_NATIVE_CPP}/libvterm/src/mouse.c + ${LIB_NATIVE_CPP}/libvterm/src/parser.c + ${LIB_NATIVE_CPP}/libvterm/src/pen.c + ${LIB_NATIVE_CPP}/libvterm/src/screen.c + ${LIB_NATIVE_CPP}/libvterm/src/state.c + ${LIB_NATIVE_CPP}/libvterm/src/unicode.c + ${LIB_NATIVE_CPP}/libvterm/src/vterm.c ) +target_include_directories(vterm PUBLIC ${LIB_NATIVE_CPP}/libvterm/include) +target_compile_definitions(vterm PRIVATE VTERM_STATIC) set_target_properties(vterm PROPERTIES POSITION_INDEPENDENT_CODE ON) -# JNI wrapper library add_library(jni_cb_term SHARED - ${CMAKE_CURRENT_SOURCE_DIR}/Terminal.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/mutf8.cpp + ${LIB_NATIVE_CPP}/Terminal.cpp + ${LIB_NATIVE_CPP}/mutf8.cpp ) target_include_directories(jni_cb_term PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/libvterm/include + ${LIB_NATIVE_CPP} + ${LIB_NATIVE_CPP}/libvterm/include ) target_compile_features(jni_cb_term PRIVATE cxx_std_17) @@ -48,7 +43,5 @@ if(ANDROID) else() find_package(JNI REQUIRED) target_include_directories(jni_cb_term PRIVATE ${JNI_INCLUDE_DIRS}) - target_link_libraries(jni_cb_term - vterm - ) + target_link_libraries(jni_cb_term vterm) endif() diff --git a/lib/src/main/java/org/connectbot/terminal/TerminalNative.kt b/lib/src/main/java/org/connectbot/terminal/TerminalNative.kt deleted file mode 100644 index ac461f8b..00000000 --- a/lib/src/main/java/org/connectbot/terminal/TerminalNative.kt +++ /dev/null @@ -1,230 +0,0 @@ -/* - * ConnectBot Terminal - * Copyright 2025 Kenny Root - * - * 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 - * - * http://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. - */ -package org.connectbot.terminal - -import java.nio.ByteBuffer - -/** - * Terminal emulator using libvterm via JNI. - * - * This class provides terminal emulation without PTY management. - * The caller is responsible for: - * - Creating and managing the PTY - * - Reading data from PTY and feeding to writeInput() - * - Handling onKeyboardInput() callback and writing to PTY - * - * Thread Safety: - * - All native calls are protected by a non-reentrant mutex - * - Callbacks MUST NOT call back into Terminal methods (will deadlock) - * - Safe to call from multiple threads (serialized by native mutex) - */ -internal class TerminalNative(callbacks: TerminalCallbacks) : AutoCloseable { - private var nativePtr: Long = 0 - - init { - nativePtr = nativeInit(callbacks) - if (nativePtr == 0L) { - throw RuntimeException("Failed to initialize native terminal") - } - } - - /** - * Feed input data from PTY to the terminal emulator. - * This processes the byte stream and updates the terminal state. - * - * @param buffer Direct ByteBuffer containing data - * @param length Number of bytes to read - * @return Number of bytes consumed - */ - fun writeInput(buffer: ByteBuffer, length: Int): Int { - checkNotClosed() - return nativeWriteInputBuffer(nativePtr, buffer, length) - } - - /** - * Feed input data from PTY to the terminal emulator. - * This processes the byte stream and updates the terminal state. - * - * @param data Byte array containing data - * @param offset Starting offset in array - * @param length Number of bytes to read - * @return Number of bytes consumed - */ - fun writeInput(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): Int { - checkNotClosed() - return nativeWriteInputArray(nativePtr, data, offset, length) - } - - /** - * Resize the terminal. - * - * @param rows Number of rows - * @param cols Number of columns - * @return 0 on success - */ - fun resize(rows: Int, cols: Int): Int { - checkNotClosed() - return nativeResize(nativePtr, rows, cols) - } - - /** - * Dispatch a keyboard key event to the terminal. - * This generates appropriate escape sequences via onKeyboardInput() callback. - * - * @param modifiers Bitmask: 1=Shift, 2=Alt, 4=Ctrl - * @param key VTermKey value - * @return true if handled - */ - fun dispatchKey(modifiers: Int, key: Int): Boolean { - checkNotClosed() - return nativeDispatchKey(nativePtr, modifiers, key) - } - - /** - * Dispatch a character input to the terminal. - * This generates appropriate escape sequences via onKeyboardInput() callback. - * - * @param modifiers Bitmask: 1=Shift, 2=Alt, 4=Ctrl - * @param character Unicode codepoint - * @return true if handled - */ - fun dispatchCharacter(modifiers: Int, character: Int): Boolean { - checkNotClosed() - return nativeDispatchCharacter(nativePtr, modifiers, character) - } - - /** - * Get a run of cells with identical formatting starting at the given position. - * This is the primary method for retrieving terminal content for rendering. - * - * @param row Row index (0-based) - * @param col Column index (0-based) - * @param run CellRun object to fill (reusable, call reset() first) - * @return Number of cells in the run - */ - fun getCellRun(row: Int, col: Int, run: CellRun): Int { - checkNotClosed() - return nativeGetCellRun(nativePtr, row, col, run) - } - - /** - * Set ANSI palette colors (indices 0-15). - * - * This configures the 16 ANSI colors used by terminal escape sequences. - * Changing the palette triggers a full redraw with the new colors. - * - * @param colors IntArray of ARGB colors (must have at least 'count' elements) - * @param count Number of colors to set (max 16, default: min(colors.size, 16)) - * @return Number of colors set, or -1 on error - */ - fun setPaletteColors(colors: IntArray, count: Int = colors.size.coerceAtMost(16)): Int { - checkNotClosed() - require(count <= 16) { "Can only set up to 16 ANSI palette colors" } - require(colors.size >= count) { "Color array too small for requested count" } - return nativeSetPaletteColors(nativePtr, colors, count) - } - - /** - * Set default foreground and background colors. - * - * These colors are used when terminal content explicitly requests "default" color - * (different from ANSI color 7/0). Changing default colors triggers a full redraw. - * - * @param foreground ARGB foreground color - * @param background ARGB background color - * @return 0 on success, -1 on error - */ - fun setDefaultColors(foreground: Int, background: Int): Int { - checkNotClosed() - return nativeSetDefaultColors(nativePtr, foreground, background) - } - - /** - * Get the continuation (soft wrap) status for a visible screen line. - * - * A line is a "continuation" if it continues from the previous line due to - * text wrapping, rather than starting after a hard newline. - * - * @param row Row index (0-based) - * @return true if this line is a continuation of the previous line - */ - fun getLineContinuation(row: Int): Boolean { - checkNotClosed() - return nativeGetLineContinuation(nativePtr, row) - } - - /** - * Enable or disable bold-as-bright color promotion. - * - * When enabled, bold text using low-intensity ANSI colors (0–7) promotes - * to the corresponding bright palette color (8–15), matching xterm behavior. - * - * @param enabled true to enable bold-as-bright, false to disable - * @return 0 on success, -1 on error - */ - fun setBoldHighbright(enabled: Boolean): Int { - checkNotClosed() - return nativeSetBoldHighbright(nativePtr, enabled) - } - - /** - * Close the terminal and release native resources. - * After calling this, the Terminal instance cannot be used. - */ - override fun close() { - if (nativePtr != 0L) { - nativeDestroy(nativePtr) - nativePtr = 0 - } - } - - private fun checkNotClosed() { - if (nativePtr == 0L) { - throw IllegalStateException("Terminal has been closed") - } - } - - @Suppress("unused") - protected fun finalize() { - // Failsafe cleanup - close() - } - - // Native method declarations - private external fun nativeInit(callbacks: TerminalCallbacks): Long - private external fun nativeDestroy(ptr: Long): Int - private external fun nativeWriteInputBuffer(ptr: Long, buffer: ByteBuffer, length: Int): Int - private external fun nativeWriteInputArray(ptr: Long, data: ByteArray, offset: Int, length: Int): Int - private external fun nativeResize(ptr: Long, rows: Int, cols: Int): Int - private external fun nativeDispatchKey(ptr: Long, modifiers: Int, key: Int): Boolean - private external fun nativeDispatchCharacter(ptr: Long, modifiers: Int, character: Int): Boolean - private external fun nativeGetCellRun(ptr: Long, row: Int, col: Int, run: CellRun): Int - private external fun nativeSetPaletteColors(ptr: Long, colors: IntArray, count: Int): Int - private external fun nativeSetDefaultColors(ptr: Long, fgColor: Int, bgColor: Int): Int - private external fun nativeGetLineContinuation(ptr: Long, row: Int): Boolean - private external fun nativeSetBoldHighbright(ptr: Long, enabled: Boolean): Int - - companion object { - init { - try { - System.loadLibrary("jni_cb_term") - } catch (e: Exception) { - System.err.println("Failed to load JNI library: ${e.message}") - } - } - } -} diff --git a/lib/src/test/java/org/connectbot/terminal/AccessibilityOverlayTest.kt b/lib/src/test/java/org/connectbot/terminal/AccessibilityOverlayTest.kt index d0d4dff4..bd060449 100644 --- a/lib/src/test/java/org/connectbot/terminal/AccessibilityOverlayTest.kt +++ b/lib/src/test/java/org/connectbot/terminal/AccessibilityOverlayTest.kt @@ -16,7 +16,7 @@ */ package org.connectbot.terminal -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking diff --git a/lib/src/test/java/org/connectbot/terminal/ReviewModeTest.kt b/lib/src/test/java/org/connectbot/terminal/ReviewModeTest.kt index 48235c49..8213867e 100644 --- a/lib/src/test/java/org/connectbot/terminal/ReviewModeTest.kt +++ b/lib/src/test/java/org/connectbot/terminal/ReviewModeTest.kt @@ -17,7 +17,7 @@ package org.connectbot.terminal import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/settings.gradle.kts b/settings.gradle.kts index 13bab116..1cebde44 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + includeBuild("build-logic") } dependencyResolutionManagement { @@ -16,5 +17,9 @@ dependencyResolutionManagement { rootProject.name = "termlib" +include(":lib-intf") +include(":lib-native") +include(":lib-wasm") include(":lib") +include(":benchmark") include(":test-app")