From ec9edff6920c5a6e4acadab010fe337d47dc094f Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 18 Apr 2026 21:41:11 -0700 Subject: [PATCH 1/5] chore: split native library out Move the native library to its own location so we can make a separate implementation that uses WASM. It will be slower than native, but will help with host-based testing. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + lib-intf/build.gradle.kts | 17 ++ .../org/connectbot/terminal/CellRun.kt | 2 +- .../connectbot/terminal/TerminalBackend.kt | 102 ++++++++ .../connectbot/terminal/TerminalCallbacks.kt | 100 ++++++-- lib-native/build.gradle.kts | 60 +++++ lib-native/src/main/cpp/CMakeLists.txt | 45 ++++ {lib => lib-native}/src/main/cpp/Terminal.cpp | 61 ++++- {lib => lib-native}/src/main/cpp/Terminal.h | 8 + .../src/main/cpp/libvterm/CONTRIBUTING | 0 .../src/main/cpp/libvterm/LICENSE | 0 .../src/main/cpp/libvterm/Makefile | 0 .../src/main/cpp/libvterm/bin/unterm.c | 0 .../src/main/cpp/libvterm/bin/vterm-ctrl.c | 0 .../src/main/cpp/libvterm/bin/vterm-dump.c | 0 .../src/main/cpp/libvterm/include/vterm.h | 0 .../cpp/libvterm/include/vterm_keycodes.h | 0 .../src/main/cpp/libvterm/src/encoding.c | 0 .../cpp/libvterm/src/encoding/DECdrawing.inc | 0 .../src/main/cpp/libvterm/src/encoding/uk.inc | 0 .../src/main/cpp/libvterm/src/fullwidth.inc | 0 .../src/main/cpp/libvterm/src/keyboard.c | 0 .../src/main/cpp/libvterm/src/mouse.c | 0 .../src/main/cpp/libvterm/src/parser.c | 0 .../src/main/cpp/libvterm/src/pen.c | 0 .../src/main/cpp/libvterm/src/rect.h | 0 .../src/main/cpp/libvterm/src/screen.c | 0 .../src/main/cpp/libvterm/src/state.c | 0 .../src/main/cpp/libvterm/src/unicode.c | 0 .../src/main/cpp/libvterm/src/utf8.h | 0 .../src/main/cpp/libvterm/src/vterm.c | 0 .../main/cpp/libvterm/src/vterm_internal.h | 0 .../src/main/cpp/libvterm/t/02parser.test | 0 .../main/cpp/libvterm/t/03encoding_utf8.test | 0 .../main/cpp/libvterm/t/10state_putglyph.test | 0 .../cpp/libvterm/t/11state_movecursor.test | 0 .../main/cpp/libvterm/t/12state_scroll.test | 0 .../src/main/cpp/libvterm/t/13state_edit.test | 0 .../main/cpp/libvterm/t/14state_encoding.test | 0 .../src/main/cpp/libvterm/t/15state_mode.test | 0 .../main/cpp/libvterm/t/16state_resize.test | 0 .../main/cpp/libvterm/t/17state_mouse.test | 0 .../cpp/libvterm/t/18state_termprops.test | 0 .../main/cpp/libvterm/t/20state_wrapping.test | 0 .../main/cpp/libvterm/t/21state_tabstops.test | 0 .../src/main/cpp/libvterm/t/22state_save.test | 0 .../main/cpp/libvterm/t/25state_input.test | 0 .../main/cpp/libvterm/t/26state_query.test | 0 .../main/cpp/libvterm/t/27state_reset.test | 0 .../main/cpp/libvterm/t/28state_dbl_wh.test | 0 .../main/cpp/libvterm/t/29state_fallback.test | 0 .../src/main/cpp/libvterm/t/30state_pen.test | 0 .../src/main/cpp/libvterm/t/31state_rep.test | 0 .../src/main/cpp/libvterm/t/32state_flow.test | 0 .../cpp/libvterm/t/40state_selection.test | 0 .../main/cpp/libvterm/t/60screen_ascii.test | 0 .../main/cpp/libvterm/t/61screen_unicode.test | 0 .../main/cpp/libvterm/t/62screen_damage.test | 0 .../main/cpp/libvterm/t/63screen_resize.test | 0 .../src/main/cpp/libvterm/t/64screen_pen.test | 0 .../main/cpp/libvterm/t/65screen_protect.test | 0 .../main/cpp/libvterm/t/66screen_extent.test | 0 .../main/cpp/libvterm/t/67screen_dbl_wh.test | 0 .../cpp/libvterm/t/68screen_termprops.test | 0 .../main/cpp/libvterm/t/69screen_reflow.test | 0 .../cpp/libvterm/t/69screen_sb_clear.test | 0 .../libvterm/t/90vttest_01-movement-1.test | 0 .../libvterm/t/90vttest_01-movement-2.test | 0 .../libvterm/t/90vttest_01-movement-3.test | 0 .../libvterm/t/90vttest_01-movement-4.test | 0 .../cpp/libvterm/t/90vttest_02-screen-1.test | 0 .../cpp/libvterm/t/90vttest_02-screen-2.test | 0 .../cpp/libvterm/t/90vttest_02-screen-3.test | 0 .../cpp/libvterm/t/90vttest_02-screen-4.test | 0 .../src/main/cpp/libvterm/t/92lp1640917.test | 0 .../src/main/cpp/libvterm/t/harness.c | 0 .../src/main/cpp/libvterm/t/run-test.pl | 0 .../src/main/cpp/libvterm/vterm.pc.in | 0 {lib => lib-native}/src/main/cpp/mutf8.cpp | 0 {lib => lib-native}/src/main/cpp/mutf8.h | 0 .../org/connectbot/terminal/TerminalNative.kt | 198 +++++++++++++++ lib/build.gradle.kts | 38 +-- lib/src/main/cpp/CMakeLists.txt | 43 ++-- .../org/connectbot/terminal/TerminalNative.kt | 230 ------------------ settings.gradle.kts | 2 + 86 files changed, 595 insertions(+), 313 deletions(-) create mode 100644 lib-intf/build.gradle.kts rename {lib/src/main/java => lib-intf/src/main/kotlin}/org/connectbot/terminal/CellRun.kt (98%) create mode 100644 lib-intf/src/main/kotlin/org/connectbot/terminal/TerminalBackend.kt rename {lib/src/main/java => lib-intf/src/main/kotlin}/org/connectbot/terminal/TerminalCallbacks.kt (69%) create mode 100644 lib-native/build.gradle.kts create mode 100644 lib-native/src/main/cpp/CMakeLists.txt rename {lib => lib-native}/src/main/cpp/Terminal.cpp (96%) rename {lib => lib-native}/src/main/cpp/Terminal.h (95%) rename {lib => lib-native}/src/main/cpp/libvterm/CONTRIBUTING (100%) rename {lib => lib-native}/src/main/cpp/libvterm/LICENSE (100%) rename {lib => lib-native}/src/main/cpp/libvterm/Makefile (100%) rename {lib => lib-native}/src/main/cpp/libvterm/bin/unterm.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/bin/vterm-ctrl.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/bin/vterm-dump.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/include/vterm.h (100%) rename {lib => lib-native}/src/main/cpp/libvterm/include/vterm_keycodes.h (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/encoding.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/encoding/DECdrawing.inc (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/encoding/uk.inc (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/fullwidth.inc (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/keyboard.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/mouse.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/parser.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/pen.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/rect.h (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/screen.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/state.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/unicode.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/utf8.h (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/vterm.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/src/vterm_internal.h (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/02parser.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/03encoding_utf8.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/10state_putglyph.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/11state_movecursor.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/12state_scroll.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/13state_edit.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/14state_encoding.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/15state_mode.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/16state_resize.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/17state_mouse.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/18state_termprops.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/20state_wrapping.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/21state_tabstops.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/22state_save.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/25state_input.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/26state_query.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/27state_reset.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/28state_dbl_wh.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/29state_fallback.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/30state_pen.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/31state_rep.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/32state_flow.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/40state_selection.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/60screen_ascii.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/61screen_unicode.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/62screen_damage.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/63screen_resize.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/64screen_pen.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/65screen_protect.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/66screen_extent.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/67screen_dbl_wh.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/68screen_termprops.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/69screen_reflow.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/69screen_sb_clear.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_01-movement-1.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_01-movement-2.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_01-movement-3.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_01-movement-4.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_02-screen-1.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_02-screen-2.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_02-screen-3.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/90vttest_02-screen-4.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/92lp1640917.test (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/harness.c (100%) rename {lib => lib-native}/src/main/cpp/libvterm/t/run-test.pl (100%) rename {lib => lib-native}/src/main/cpp/libvterm/vterm.pc.in (100%) rename {lib => lib-native}/src/main/cpp/mutf8.cpp (100%) rename {lib => lib-native}/src/main/cpp/mutf8.h (100%) create mode 100644 lib-native/src/main/kotlin/org/connectbot/terminal/TerminalNative.kt delete mode 100644 lib/src/main/java/org/connectbot/terminal/TerminalNative.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0aa3ee31..75cd1259 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95133923..6e710b11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,6 +50,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } 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" } diff --git a/lib-intf/build.gradle.kts b/lib-intf/build.gradle.kts new file mode 100644 index 00000000..bf3f9046 --- /dev/null +++ b/lib-intf/build.gradle.kts @@ -0,0 +1,17 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} 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..601ddcbd --- /dev/null +++ b/lib-native/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} + +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 { + api(project(":lib-intf")) + + testImplementation(libs.junit) + testImplementation(libs.mockk) +} 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/build.gradle.kts b/lib/build.gradle.kts index 4e7fae60..9a7706bc 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -26,38 +26,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 +88,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 +102,7 @@ kotlin { } dependencies { + api(project(":lib-native")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) 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/settings.gradle.kts b/settings.gradle.kts index 13bab116..cbfb34c6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,5 +16,7 @@ dependencyResolutionManagement { rootProject.name = "termlib" +include(":lib-intf") +include(":lib-native") include(":lib") include(":test-app") From dbc1b5e2bfb28fe13db5d3bc1497ff4aa286961e Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 18 Apr 2026 21:42:56 -0700 Subject: [PATCH 2/5] feat: add WASM based backend Add a WebAssembly based backend that is more portable than the native library. It works by using Chicory which can use wasm bytecode directly in the JVM. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 8 + lib-wasm/build.gradle.kts | 101 +++ lib-wasm/src/main/cpp/CMakeLists.txt | 42 ++ lib-wasm/src/main/cpp/vterm_wasm.c | 647 ++++++++++++++++++ .../connectbot/terminal/wasm/TerminalWasm.kt | 578 ++++++++++++++++ lib-wasm/src/main/resources/libvterm.wasm | Bin 0 -> 206229 bytes .../terminal/wasm/TerminalWasmTest.kt | 148 ++++ settings.gradle.kts | 1 + 9 files changed, 1526 insertions(+) create mode 100644 lib-wasm/build.gradle.kts create mode 100644 lib-wasm/src/main/cpp/CMakeLists.txt create mode 100644 lib-wasm/src/main/cpp/vterm_wasm.c create mode 100644 lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/TerminalWasm.kt create mode 100644 lib-wasm/src/main/resources/libvterm.wasm create mode 100644 lib-wasm/src/test/kotlin/org/connectbot/terminal/wasm/TerminalWasmTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 75cd1259..1136809d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.release) + alias(libs.plugins.wasm2class) apply false } spotless { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e710b11..91c7291d 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" @@ -46,6 +48,11 @@ 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" } @@ -57,3 +64,4 @@ 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" } diff --git a/lib-wasm/build.gradle.kts b/lib-wasm/build.gradle.kts new file mode 100644 index 00000000..fef74815 --- /dev/null +++ b/lib-wasm/build.gradle.kts @@ -0,0 +1,101 @@ +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) +} + +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 { + api(project(":lib-intf")) + implementation(libs.chicory.wasi) + + 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) +} 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/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 0000000000000000000000000000000000000000..c78e48831372d5dd4b16a07b1058261e644857fd GIT binary patch literal 206229 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%0DUd9S)@P z2Olwzf2xTrcoOM?dh~yk3*iVAv7rM4Mv@se$`CCcB_S1IWhp}+=qN|gp8kgyp@A18 z44x_;tL3F@&YAZewY>D$Ir7??8yG>ffQl(_O}9;=f=R+Ks^J13@(pEiiUlOld7Xp9 zC|&eEHpvtn870h^Cwo|N5ycmPytb5y5gn`6ig6&2dt$vhTXt>0o;rsUqPap<32dJo zbR{%X-~O07LP4U-8B8$O`^|)RdF)ei?*U-zYq1&GZCO_SmWmBzI5``ntJy#usv!?! zaRVD4hh3l5S}{2%fQgYom=pj?_(cL^ zRyw_Gpd8qWd+W`L3edv5%7PgUp0Z)^{y2#_%IkC`@1uGi8#fA1L|f1(Ia zIrGL7&khj*QE%S(WAGye2_pkep_w@h0(|?H`i*nFZ`8a{&!cOCl0!nvz@RcI@l)aY zOt?NBuAdCor?@KrbFr9(dU(z`(t%&5h5SCn0WSZm@G#pEQrfhXc1sX8@&j5MS5f^_ z;w~{NWfh}EnYuN>gg^sR(%oVlu}f8Apw&*+7%2D0V5gxt<%NGm5*i{YHfH;uq zraB+0RP7+44}UF`xX2=nf&HOa+4k#6UOKPx`Uqo~UcUS0)O3T&r z<*ZXWv0HwLX0BU8_w->G-A*0G8(~?ya!)h~?;LJIi7OMbazUOe0)zz8u4c+wV0v@N7b z#ab$(#KQW>+@s11mPZv9qpdejn>DPuq`4uhY9(JZc&|8%6|XI9*is$CZE(VcbZopU zibvaX;wZP|78yoGiO2UKG03MJ=}@QE46-dlJAGzWjmWm^_)uI>nkjAzUc{WScoazA z_a7`2)RVJqUSTwxrG;Cv*~7!xY}*L95j6v3OP=8ZEbxMs6$o4U-JZV=m1va;-Sv>Q zmxvq;wJq9;ly?~}bk{{@_mh_TXNxaV^Jx||7!LGqKop1(B&JCwv#i4}vdB*}7C^6I zvSbS*ad?q9qL*DHcM&F4cG2?iqHGp{9MLMBJWLiy13kBip3EQAufV=2|MegF%pXX< z!1Nd8-Giez+qRTJ-@rw;jJD5Fb_*?p>gxLuWg(uy>T1%reR@Xm{Fa5ugmM=wQe=wQ zj^bTxEHm8Fm+p*=GX@gL!_DL{a@k};#!VbdZ^cK%nArb9%mT_};B)b0NQw-2)K+Ea zTZXjAz`?w$G7v5fghr78$K0w6TP;Jg$UrdmstnA#fIuXx^`#EtUX{UOy7P9Cfqe_B zGGqpUd4)q@w6D%FZ#kxl912S*VL%_zjE)MYPhfIuiCu$MrYh&AY4&8nSOjB~qEx2A?w{+`O&M&D*U)GeeKx3_!H48_wvKZGpuZ zQuYpOV_+EK!~Or+d-DLfs$zfq+_l%~>6xA_naSi%XPa!0V1R6pbcm1;0+BT;NVWkc znam_JlaQ!Dz=$G|e z!@)^2#E4TP93ohgA~D@=q;59`t*e7p)aBfp7+BQWflpnbcF;_ZBDxLpC+Dpts5W$z zu0eYnxXaRKaT9l6s=T&MG&Hj(s9#+g`tH^(iXIYWPy|l2gzXUCZUhGmD*vmTMOZ>4M^Qj#E?Vj4hB_2R;^vMKDmM|fenlo^ZNdlE3XyX*zhOgChjHsX$!$E3`!4ghqwH*aMd_MxxhWwF&wiF<` zVj&1FgTN-M>%I>bgL(;EgL&@fi@`i~FcgCwV6o(&uDekSVs%G5JX#a22Q8I5(*0Dl zoLFC)Vw!ZKCM)PP?b|^!w&Y|!ZVu}4SYxwRDdAz{yps+?QLAA&=OsRNSw-crK<(E#q zCzSt&#N`E=A9WBWa_7l1%&i^xep>x20?x^m5p6>G-CW{B>}=HOxG%C%U{Npkqzg7fEQt75bTY;L45cG6DLDo zo+Yr7&-%fBsa@13de#P6vBTE}4f%_O#s)g5T+l#4;0CV+jm9QO0U7lo9&w8p4U|f2 zGT;*o8KAWu$^rtZBuWfI9QY2p2@156dh~!=3Ifo9A%C(CeU)QFP6Y74D5`rF&e7qn9a=g^(zgU1Kh&Dv@wJW)D0UHqjw+om%FW-B zLy4H$>#G+;S4&?fTKWo)TJ?*q9ixNdhdV~GX2*Obf3KXh5@S|mP)A!9Vj1@i3ksOA zkOKr^a%qbVQEssp78_cNjWW!oZSLw)5Hw;tkPSA3_r+ooVhL0%%Lq+igN4yM0y)H0 z3<;Y!IY_LHMF?aHoiXutcpa^q5}<$QS4+q?h& zbkK0OVWFo=7ug6X)Pmwz?2@{0KVDF8k5d=JL)`*cWa2KmSXn;`#jn{OHo2NPg6oQ}K9leb^H3&Xk z92vlwpZljLoh_KR1Ud19y>YeP)WstwaMt?Dp=hUp`3>|xB!w=9JuHR~ks5S3P;qXW ziG+g2H6j>9SOs#9ue`{@3X4FX(9L03{`PVy(HV>>J*W2V7?0Qv*9WaV zbT{xibWij-@n!=tQaDi-=Ms!dUc*ka9_OGPDdr;(n*nB zd4w3g2kTbS&*yhglVwV&9!x-FgcBYaOAEnxnt3&6JWz*Vwm|XI&=69Ba*lM5wud2xJ8K$TZM0O*;^bR6xcP}4(jzcc2HtGB95AH*KO`T z9UWwVddlA#Y1JCn3SFc#;II8znfaFDAIoNphu${FAg_eTn{%X>R+EopGa9xw7CRC5 zHV2*1wh8Ec6sQ@^rYCbpe8Sf{CIp;GzyoC^Dy`sGCR`MB(19?QQymlrl?|bkRw5V^ zj9tB2t0LIv^esh#2I_(~Cdrhb0#e}uMHG+5EAo5y9B?f@UuJ29zW;&>iSR{Hprdq; z+{9^L6NU+}hI*_a8Wv+5usNW+rg)3odJuz_aT^Yz8P;}icMl7OlLlaQO(iR6YK0qU z*&kV-+JAkqfHlNw#F;1I@ps@OR(Fh$YQ;`y*YVuRlJJZjP!SmWz(^36mD209B`^sk z;!=zGqD{rZI_+ApG8Oxw>W;wvWa(Z`Kd90fG%<)+4I{uzN#o^1;PuF@$J5_U^cT8N z3x;dw4`KV_ykyOWC#C8K5Qc$Gb483tRv0EChFGGA7><*nk_5(F)7A$V6Z4uv zEE=1U>S!GVhQz!6May{3duqU(*o(v+Z{Q3nKyXkt__g4^w!_)3#Y6G`m?{W3%_ ziW_`oZt$)YlAKM75r;`+6<2o*$0#uNan~2yz}~Q6IKJM9JRlX(Phls~PvO5G{k&%o zfUe6SLOklB9|fUXTyrF9y1U~1qD-rf(dEMBTygI zK)cIPBa9r=ZgbF%7?~q7hE=2DrP~>9!l8tLEQ+8ITnuNmCSyq;w3Y5vr$f0JsUg1x z@~F{q5l&2QXH)T2ru+;nrsvAVCOnDoh}eeC*~p*)J~NaE)SJW?LZbqq5hJM4ga8Jx zu(iw#(#MNK(=uq~<}-@y&+$V{2_55>DcdWh5E$Y1m0tSceYw~ZDDv6LXcf;@Y|z$$ z=}WeOMOyN36#O~5;baMik7KA|W$k)j$w1i_G8#b-EnpQ((3o-*^w=~94cz-GOA8d3 zgb|R4E)65=Y1djfO-|+JS*FRXM)cUnif>Zo?f;o)ITG8hM`90 zO(o16m@>?Ivpkfe@TkP7mH*G6)}NN2M%-mwLVq2Qo|yG-Ynb1kUEoC45J%K9&iaF`N7oqTEXm z3DM!OK6Fy%Em|>*8hxNftZ2E?{{%ya^$aet>7XOwa#)6#ESPZdMF`ADzl38GE=H6l zE<=`dRNC!l+3meS+@Ffh8%J89^KEDkhbYcOFnfT5rn zhJ)z<(-H%@aCZYUJf;>XTOpj`EB7~0zAdiuZARr0+slopvPH>u#e3~C2{PK&+3|S)8~3x`lT2zHL0lYnL4*34Y8*oOXb!{{Uke^IEO0Kwx&*Etnu%y8 zXh!@$98+uwH7;#=3qA` z2s+KUAnx5>!BL`O0}oBWRWz6lyefuq*RN7H%C^!Dg5%=?LcXzHb2t;a?E@_RUUo)C>+;{&L=| z!PLF|&|!H9X=gIuvhWz0c{*u}9X*|7&qW;vhKWZLxiFXc1vz+vjLOf$!lK=(U#7$Z zQGsi$)82t~qC(_;SQSc-$L8cNM1>Q>eeH5N4_z4D#yhg5GfvrxRoyned7S?ixf-6| zs+n5}20$+El+RIcY$(v9U*yIzNRqt}tIl5oe((%|_BYmCJltP8N%nn8_>Cj;Y(F2> zi<$}hkk_29IvL$&zBs|kgaX4e_m(!lof{nQ2%hmSp1|dg(b{vFeDIV9k#xA>qs{kTTME05?TY&N;Nv~GRr@hcy@(iYc-ETI6Ub)&QeqGL2Gf8^ZY9A8&e6+PHOgnr&oyYQZRo=u_78MrmcG1< zUMlF86XnOzoYHkCgA_h2c&Q-zL2>c6N z8}wDl##6NV>TUX}DH}lNxZu#VAF#SK9i=Ede3p%b?qQ#0xDyeSHfhPM!3}!6_*2Bw zk1~j;5$7HTQCfhO@hiWqO`9q4{U<*2PMs{hL@n2&{<=JnjW_#{U07Xu1>aOsr3{~l zFX4M5?nc;zd3GH&(ad`MP;|)}z;8u6{7%Ey*#WxZlTDWrFJgU?l;3p0K>7MJA$!=; z3l}r6C3)|gqk=d6MHY2>GdL7t`xz3?4W zZw-H*2i@?60F=@cf}A8Hf+Xe(BaaT;&&HeWl!tlU247)80cYc2F$J&1{(}@4maYS@ zLq5Al$?@Y&_)e3)8IBX)U!vw@aY`Y@CpvLQoVRB*66u&b{cnn-{IEAAjt)dV57qgICxg%)_f0c+M0O zaONWBg~P*`vJ9y~CA7=;WON!8xcEc#fdR?l8L^Z2Xe=J>fnwu~n-&h-k{*Xh9;!65 zBMDMr0NBfBNv|}P65V(#UX0jC<%Wh2t|{$}M_suKKuLu@6vG!2CC8A%ARG`yQ+RG0 zg{+Nha2&aB>tF|7BkD+nG7ja5_r!RM#FsI^H(jJ9jzR=sO^2zWkri3Xmcq#Bz3n3gA_64Mi^MiH_EP!T3gyLZ_;?#@;wI?)7prNrsOf+98tfou+ za}kP$O=GxebdJF%J$Utk{t#8+D2L`=$Y(td%8uLelZ~ATBdJ#Q4iC@)-CWw zGEo5gU{iZ`b$M!kpNSo+i$R3=N=$V-Y6GjMhXJ(&1-!$Dq}!6YH$ zw0a?bqy_m&nR?N7{)&?kLV@Cw@E`;{kFTkDL{rC2LFhi8B!g$hhXIgS&2d?SZAko< z1|F1OjB38Vr?!Zn_@nI0yfGDW=JO|f!5N%rfP0^K2*WqM!=(9G20T2jtqCBvs6~8v zD{#6kO5Rcs>$$rh?K#2u|F^2-O zf|PEO{?TA`Xdv9u^y3B00N*}BiZAPtyXmX78bq=Lm5gshu3!P$fe&hMz`z*?$HaVK zPygd}9q;l(E2T(D*b`L*Rrr2kYBgW^3Y7!SSQ=nui9PlAX*6-*lV&aX#1Tk@5ht?4 z$Tb$9q7gC9a_|Z>yhC*O1BavHi=uGUq^iSF$;NO?s8ZTVIBM$dFdW5TY=Y!XkO_@o zGzkfDnPuD(MN%1|Qs&~FAPaOY!lo`uOzZL?Kjwr$+ zYaJ4{Vu%q0&^Jq`&<0GGSdG(*ZQnf@3cH8=b@y(NG2P3faniO780d%(ALxYEM`}Tp zapr2pz(gl{Y5axUDRL$&l8#9=6KWDG(3Er~VUS@Hl?tL;$e+`pR>4ptXo|}hmP+{| z%!%X+dT^TzYXL;Ufhz3@DPS;GmrC-EG6a@ijR-we8nrWF#Ks~M3IliD#@n}1-}b}@ zWF#e8x~I)Ip;MSh_RyD++OaZ2#GU!6t9}VdlcPO86>MJ*RKCbRyoe zf=6dii}$JZREpq*lN-ehRg)z*&Trz$bG@9qhIS1 z>7sX0zASWw!E-4KAA^`0lQu#>&QR#ignkKxez;f}`t_Ig9$;*Qapb3ST)b-qSviRO z%mT4PmHmm1D^nm2OCg_{ES*!cpr{~F3UCDRDwzbjP~2f?jUl3=Lqv)7BuWfYQGkvZ znoG738yV{|&|^@54>hmH+wTmCQWUd%j74l*caQ<22`~&ncYqFP8X^^l5IU#n;yd>! z&!q&I!oH6SF4fOi?(r^0Dg?`YEI4IMcyXhSENO0GhMgwpW73pH9u}S$3{ZhL z=ecahdFPgbA8^8TS_(JEz&+N}F-r-**^rcvd2srorQpOxFIdXrM7se^#$M2=u@@Ki zf-&5&y-4l0AhEr@c%U4=&`T3hG!eY84|X^%fr?B7Zy54z%S1>D38P%->9;c&ERz+@ z(3y*c01~pWa&_fG7@}kEBXY9les#HPmo~zH<_6eV~Q`0(@&w? zCVaGm(<#DwWaI7oHQK9F-HeCRdqn77yco1FbcKj#xa+0^6nr{D3kRx$5s~UbU{u|Z z%j$Y)r724apg2F%BJ@ZRT*GUHTm-q%`W$)k z(>3%t<@Gs!oS|q-8To_|UjqcAyw0?|yh!`OV3J)-Lm-Z7q`M&z#7&u)dDol*+; z{dyS_nPJ0CMoEP39bX~O2l7N5K7U~>NibLv3>LPIO%iQMAsc9yOyk!^SOf08%(Gm; z7i!D}Y(XGggbg7?MIpS3$r;o#!44dcnM>({OsE4)yKfbD-!h!huwl$Cl%xBmuY|@U z8M$v;2Re{pk~=bRrL+!g8qijtLE2|Qtj2w#ZFXwhw}Sh|Q+IIRw%+N2>JCb{1My4k zHvNOXc??!L+_CJrBu|Grh}#0VHhB@*V!tF2t|ao~9_819<_t}w5Mzvv>BrF{0v~)s zk9%W~DE)Xs{CJ{>g!pj@wrz$!&=&e}l!J-WkIOJ682V7#qB~S`xvmYfJ%*RD(ll;jc*3L7}0YR`!FXPM&JP6#? zb_>}^rO<9AjS3XK%!W#KDe0u(RPkyhp0uPV)#?wGfDUR8=jf2qf(XS7N;q@?%@rpn+HzgiU2_y_lCkTMtk^z6?6lL!{L@0L{A9N)g8?z5SN}$B00qr z1$!;j1tU?7iU!HuMCn12^8Izl9)0X+(g_;~Vts^e3ZtTJk3J1NRVXFcFdHIJOWHA5 z+NoMrI3vDZiq>R~VH?y_uF|UX)WOtASw(v4M0$!m(Nk3Z7xk3OZZM}8p%`#kDQ!dacuB|cOGPnWZX1cj*qOFJKC8{kIf(Hqab*rg?cJ;={13M&51?lGMO3`KJ~PP``f4r zGvRN&;yn(27lwwLmxlxN{Wi{GlU4i+Sr~I@9G{Q~uqg$%9a#Bn{xmC-Q27>~P2R8= zK0JoE#_%@c7#g&JZb!tfw8!v~#8W7h4BbmI9wVWz)ndn*wA>p$%&4 z=zhTlBCKDXg-q4DgvTiWDNWK(+9|eesY88kxnzOtp*Z?A_4QGhXfe>7MQt z8wb1k*LIFswab`Q<5jB~SlQpz)q8Y**Fg6tyMkkuclUIy3I_Xv4eM4dAM6TNZs;HA z>kroT4RjB7_w}aLAsg1M>+2s3jzKP9?ef9F?%rdAo<1B_^=(+u(-mx7-m{@=Am|=o z?f$Ob!Jf^*s_ud1xVCDS^r3x0S1<9PdvH>)eATL8a?i@i>-xKU2M1Oz@14w+CYzQ( z$KY{YK~Hz@nqXz$+O^Aj(SWK+tDXNjxKv{<+c>8TZsB}V3~x2Kk8>%8&x_&nWB5J> zPvCqafNh63`(!%{0Q@7quVCde9@1fXUfkYme&iw}e5Y7VzpN8|G!FR`bgTeQ}d85Ji z#QAuGPse$a!DryS+29j!UTyG+IIl4{zD=Tf3=TJ>)*5^loO=zvE6#ler_L-k_+B_K z1;qbI`!RqE01pD*1;qa{NHhUD0PLHzQ&-p~+gb@=zoh%KfU^J>0=@&d9&j7rCxBl7 zehYX3@ERcOrw#c3`}_Y_0{4zl>dS!CXnY_APy8q~xHBS9I0A;HG{%_MI^7#M${&ywNj_yVR0zd}cx9XHS8ZaCUwgScjCIDUpyaaf~z-u@Y{(%nv31~p4jerG!LjWfkI0t9K z#Rjg#`6|E<0Y3uV4Y&vJGXuZGneaygf5rJVz#9hM!kO?710EP9qyhN&wdyo*0M3L1 z4IF~=VSpnHoQLyQ0M{9~9cMx-CZaaL5(9_fOxS|S=K{cmfY$&D6VPLTR{(DVXvlj5 zN`UhL7XaP`cvDdnFcokn;4Hu`fI9%s0$u^MW1Pa0n4sX z>eB#qHT=r2asE5ne+$0qpGpk_OaUAWI23RU;5fhaSnG`8rFrUvH@;fP(?60LKE>0p9+erQBCEJd88p%vUv>jq}$4>aUiv0eQeI zz+Aurz!Jbm0LuVf0QH)s0>EUz?tnc3p9Y)=I0tYM;C{fvfJXt_0IvXEH}EFTd%uqM z4a~#&9l*PQz230YBEW%wC4gfApEPhD&gTOzHSjjh2mQ@bTLAX}9yIU>&c6UWX5dda zzXW(4@D^a(e_85JfSSKsYCT{8uo3V{z$Jih1Fi&I3%DQf5a3m~Ehh^E|+QfCYd>fW?4yfc1cr0jB_NGjJ!) z_W*tZxDW7(g0PGJ~ z3^)vMIN&w|x8qE>*T91~KLmIN@GPKIgMIEv6 zdksv^Lni>c8Q25oJpnTeEXDbwfMs>?OMs&b8tR)dHvo16OamMYI0VoS7y#S=xDjv@ z;D>;F4g3`6M*+VzfX^DM8o(6>f?=5V0OJ6ifZYttzAT1fL#E~04o8j0eyf+0Dl2Ym;yf!px^%*z`tBW z24}(;z*s>0+Zq&nb1fhMi~_uC;P3F!Zv(8fr5u0<@BvvsEnqreCSZ<%V}EX`ZomNG z6u@Tzrvt78TmzW*3ro!hSSlwF;CvE5SvmC=06k9#a65{?G~)sS@nyp;>{9dl)-Lbv4Z8aK`})~3>27}(d!Jxlu9d;kE zO~6e7bs%z<2ZMu~)e`JebPo(**CANd)!W^*N*%PWtA9DSA*3D>Yr6*rk$+%cFtB0e zaltXzya=!_v8ubjYvo{H|K{k*y8b?)Y+sh>+SENTIG_&aHU+ji`huS2{l{YOgEf~A zqVvt`NJlWRZuv@VK6D*37zhm;yTQdl8<09CSlhLh?SqZJ4gDy!V#B~@wXhd^EBzbR zfu>+!^T1%&+F<4Ko}O|k{4)#ScjR1yw#(`tUmrAPxULuUK*_4a~wiik2D#1I7Qy1M#fq8sd93-bCl z3|1CCX2Sp^xu9=gkQ+A}di%SUuRKnOE3I0Ewy>Ym8=#Du*Wb6Mt2bEJ4IwSu^&m5z z;SdYh+O-LLOB^MU=!Z9Ttu)(ArlU|)7_tp(p){&w z5Qqx2%Wz@`Ngc9z?TWsh?v;A;sHd-Qo#Np4E?;ZLoP`d>;A2y1bI`XAEU{Z*(+C(m z2_>t#uu0av4hjWYqcNMed{y;j^1AZ4?w(aasBbJ33WD92)veq&ceg}LJ$)RpsOVeZv(M2&K!%)UUZ~8_AtF54UDd{p z(k72ot&B3u-g0>lta;UDS}+vzSTW1;)p#zH3Q8(ke;hVjp->;$&GckhRb9b<0I$l29xSHJWwQT0x{bC>m$lT=rG%35W=YIS+j>@)0#?a9so?z7&I z{ifUICB5tIMP8%5NgZs@c3yRUuYT;D;6A9XbT)gV?OtoGQ*^I#=c~7^Yt?6*W$F)B z!adjhgga3!wPsjfMofI6wU528^*6-B{2Qh+YS=IY1XzmkV0MiW&FQ~kDxJo4C!J7M zYl=}u%E!fw@>$2!3=8v&sZ;-< z1qI2+l8DEY%Y#bkY#ig)V?}}1$C8N35Zm*R(%CrTKZB&hJrO0rp=rDCvFG#|rFr|z|M0$ur0@H_ zn>oW~u;Ku)R+5p%nnwPxW|2*9la$krxWF0#qmDoff0QQ$6-rMA3TaYKl1LDlg0?`x zvCBp4R&}k| zaO}~`SFFIaVbxhPLp;E*|muj>Y7Pvf3Yh}hyQkN|#tM#ykq?HLT8v;i# z@0lSb1$8?dPViMJ)I#-GHjJ=nW#z$byIIJV;=SQskcvg=DZxog2wOASLmWb#w5kDhhAo}0dWzAbQA`!gR#CgkA@>U!2mXB(HsXDDm z^<=6LGSyJlB#sHS3FBV&zD-nijxSZKIRhu$q77@<4%AGK>hMb2Zlxs3ZM0RY;fQLj zYaa{pb|~s9v(qwhBMu=GFD?_mQYLX#3B?mm9#I?M9=}6RR^c}&V=zxgk&F4z(0OWR zYj<#kT3gM*|E=y29OpF0wUHDcNuxN%B~~(nDP9l7oMBW9@quMKtSnn+Ra;4=_;xT4 z#HIzW!t}R`9w_I;_7iwg8+6$dfJfZG4dalqcLkr-)V}{P#Z=iXlZQF9%$W8~m^-OR zalQMOT~stQvEibE=0gSDAs@OsM-T5#3xLVKTcq=@q7R1JQhpu(7~3+qH;phL$$-9{J^A_hv7XeGLjtx8CP&kKsve% z(PrYqldT+{no#Va1APyR+>y_W_v6OZMqD9DGe~LU)pkGgaB+j+9;3jV)i_Ggi~`#iP;M@uhT^_^m7*j^XjE&=Ho^ z0kW<6*OnqWY_HVcvQXb+!l)ETSyHLETBa+l8Sy2e%ee4rG`G2s+uAaBm2x$~RHY4v z)4O!(F(5-6T-jxLg3nBcPfJuww@kQ%NqU9}%`K*vT)JSjq!)!bAhN)LPtiH~vRz{h zS{)A6>p8Fx7Y!c;QjKw0G{r0GF?pewu?)3H~~i%4}+=$2E7`L)*Cw{VyA6a&)AjjsUmFNZXgnR zAh2G>?wQtL_ze(JX1P*5-@~btt2jHQwR$q3-jv4>L036Z8G35BACef2LdC3%M0r-0 z@b!Nt$g&Fm3-N;{IIU$`vCPOuS`UJ2BJ2DY366W~N@-SaoR!&0G~>+%?H!U)|FRK+ z=Ul_3Q91O1sv8IJKa=ObqL^iR!m-yH81Cp%St~>7xNg)&q6QDu0d*KRRqlaQvna97 zG|WAamnX|x?J?cZLwjWsB{E5|m>X z8rx9SMiuR9V_{^Ri)>@pMQR{PZuMy!lVl+p1xYp-A1aB)vWmd>R=P6j4ZA*RZKB?c z3^(ChVjG7ZH-U%Ip&r(ZJz+@>XK$P;T4Rn@uC$dKQfdS_V6k=*OJrnugDi+um5t^O zPqo#sP%+lKTp1PhF3Hgu>^QLC7}TgGGB!S?=%x8M?E*XFdNV$*HxnvNWMZf{lfnpO za=B4%ne7tyV!N7aB0gT7Vy=q7rU48k{0T_0PeNYtOa!LC(jRbCL5UG?lRJ!;rIzIcGbM z;XbsNba@w;ccjBU|A?+Jhe1}J`!Z;F2iNQtJ`l;YH8dHwuJKwr(mt-JyU~+9j8kml zIvnJla`G*OqIFNR z*oNE!c(0!R$x^$Pds>D&Pxh{5p7tP5y9!UU!=9!{Pg|p&V5iES5V5lpo_g5R%yLh{ z)66QKW(iLp3wxR^J^eZA>9cHO_xJ1RW|rEw+|yCGwP^4A(MWpI^L~3$dYi(Y_zHze zEX9y%>HUmtly=k;%s0}A6Uq4@RXrV2?&%~-^HARQ;JDdC((_@`^Z8-VhfB}DhSN%w}Z!>M1_> zcfo(!msa(3X}PBhDd|i3^pm!SmqDieZKnO>!k)h)JwFrm{1n^#M!9FvJs!4qn@3U{ z*Hv|YUAcP^>GfH5cWT((_oTaz_#vYMY=bXLX`|bTNN0m<`?ji{ZY%fn9IyXSwEEJp zC%$>561PS@EoGUT$~~2Nf|MeKeuRR*zpAJE%RQY=n|`3|VoqlI!7%L%y~x3T==G{d1=Y!bhpDTOT`k3ap#Yg0I>XK!#|BS>YG8I@B31EgbBx8c#ya2Dv zu!m$&Y>T793^OXW#bIHF85YOlxMXLBM7owxc?yPDE00I#{Pqs0jA@Y28`tV)v}xB? z(_33jZ>>coiqV^o(JO&HgeMwBBpts6sH15GR6n$ooXl3h3Lkk+!2 z3&C+E$1fWxdtx=g6JrE>3`2BA_S~@dNxJt%uzlV8Jhn5os&|#1ggG)kbhD_*dsKI~ zN31(B?>($-k*Y1GIQF!DX)+vb@YAh_k&*pgNYD&T(32o&7U$)f{n^vh7(tN`Vpagr zM}Fy9j`h51(&hQ3}4)CJth8M+n7G+$dIr?+h z(*e4tYong-W;^?pdop~?s}k`sG^xX@yE{DAomlhX)z*AOwRIe!H7<)eO{`<7CTI>u z`XeIZjqK?oRRonyL`wqViA3BD++JBt+R7Md;^|geFQSj^1avH(Zk14$T@Vt|r3v{t z3ULe)Zy7`-^AM4fdj{zgn^4a0Fc1kh_h%$(U&F!q91gx!b1Er0YDc7Ns`*TCIAPUt zQpXFp#z57OxW~R2)e-}(vtEjHR@hiCY-pR75$?AbJ!Fpw`5MrCZ4LPvtm5kmSe{n0 zPLi;CN90zEyCX*U@tm1YswVuTYQncv6Mk}xaETC3u^1AN6L&~LgwI47o{kZ=S_~4h zmxQc+R7Q*UNpV0XrA6ddJ5BD)#>2~sKq`msNylw z&-EZtGy4{aJ!9w0R!)SP*)cI@AEYn7EXJ&;;$;!#*3eyPDN&`0m&cfWfF&-s{$Z5W z9g?{E3X6d>hu9mE!1~H4!ykH@Rj#raH|c_>gMTs8YqUHL59M)fl{_{< zs9FraB^yKUBL*3BIYd&?0vQ<-$fXqM&D8?AIVKP>#hWAk?ID?fZ;2*gXGkXCTcQcr z%(u5{?>887cbn$!%OQ6^tm5u)%X=hzm>m_uofc*qVz!XfE~zmBPhyn(9>{7tibTDIpgx?2L;o7-h}H29ol- z_vT`ICAnNy3o!=73A`TBYYpl3f~NN((4*H2n!X=}^!=@hzGjG2lXZtI9d<;PCMF)x zZDcrW+2zUci?nUazPe5O3TtRN)<(8%r%Z-Dv^TwpW0wP7nH*hPmI_aXUBur%A3JGWy z0(4<`uBh-F6tG9eI2MO8(hehfOFNX2!eF+=H%c97eolaFwUj@?c3LI3th2hi_}Y*= z0QSUKcQV=&?egMGqK}Do=$*|*;UrtU^B!UUlXd_9i2A>Y4UehnKV~x(eu-tLR}(Qk zM#N`eVkl8wajmAnXV~QxBEQjqj2HQezC;o->@#K9^}Hh+)3fZyQ6hV2NcU___l;Nu zXt|9cExYZA?y?wTY{VD3Yq*qHP|e1I7#p7=8w>36LiZD7V_}R9es%&GZ&b3ezt-OV zkm^O6>NQX-P4%bQ^}P3{dV5`Kp)nm^P4M9{f?tPbqJ)K={Onm_??>p~UyOR6%yvFf z)jP%umA{Ei5_erLkKwjkaX1)jbL&FqMA%2t%2BCvv{aixwU6BecXxRb-`)?Ir?Bpa z?Sp~x%OTd4&V#U&MABY`oL5hZn!?xG>0sTgR@R=EY+iY2|gcvR9oc=_Fz)S6vcM2V z68AWHB|Y}D$2zBuJnzjO&vNR>!>R1y?^do3RqICJu!PcF?+cA!pGsvFXNS2ZcBk%c-#wdOKwnL49x)A-Pi3bFn*TAB6mvR z)G)EcovwtOpOehTHE9n|7zmf92pWkYNxCPxrfnl?Jset+lE6s#dwol z&vVkf+Ah#VaVXvEY*k>q!y$EVv|W`;A%?4K#NiOOdYM z5N4p1`yJ0!bsq-iv;4Qb206PIdxd{srFtZbTmsKlcO0-T%K0l^y`0^%y~4p^*XN-~ zZYzq^o`yr+c{t=q^h0(%&q?<#P4vyU;NBZ2`T-LCBi1BsBC{`2PJg#^6u6A_oy^!~ z*D-pW%w`|A>!^}HBFX=7a`c@d$=@>r>%$x-r@G-d%xA`ZZavR|d%aueheKG33+{~= ze10m1Go5=Y2LB!m{wiL%!%Y|FVNPGi#Jw)`je0eg{dQ|@KbrKcck4cl5)H(^<<|3@ zcfaZux}n6PDqlwr^yL?^or~S1Ch>eybcUO|9=WyufkWM0I9x-?&hhGbp5lJUE7OU) z?lM@yjADVLuw#KPGYSxb-{-?sIN|_t1D>%l#u*JPMj52JjaM zk@vVH@dhl2%zuN;zU8)P=3gh-zjo_xMD7#{uF0#Hvs>#G&c|qQBwgINS2DieWsyZ* z?zbo|#7x?F1(j~7_{uOXp(n>>BT{w zvO^>cC%*=#W~ChoGWh8vD}5k};jswh!&J_bRY*jUWaS;(kF31(XiDY z0k=Ho=FTg&QLErg5?e&={$p8ZkFVCn0ye)qu9TDCkxwWXKm1J zC0<6#dlMHK*jqlU`iZSLvICV|ix%vy%uTVG6f38Gje|;SGBZ~J&t8W^&22bzj6+}D zRxX8e=3ZWU0EgN~a7de-oo9h>fk>S}EB_MkUf^~0%;mS!+^ljOoK&7i^UMH)7H$%7 zMAks!-$FtA8wiwyamRD!<4td8Hk!bXfPkOHI2cNNQnJo!Pf&L^qMkKUC3eFAoj^Qr z6PKe+jVBW`;4w~Pen&QOOqgHHCnksGiw%hrQAWy-ZAmPGa$m&qwyvL#Niq*4N;X}wh@kynQK*BAIZD9H(q?a$mLY%q2+uMIojok z4{%OKTHn^f=tO0gqYJCS8$gzryMH%2wm+88rky+$`Kd4CKwEOVt+d#Z-JYQ>-OA2K zstj%EF5-ckp)F}VnV~KHjQJhe3~fpBi}?&~N#hL}+LGX7TQanzr&xYmTZXnoEwDR- z3~hywEp)E=I37r|*(wi(laZ-l1bTa6)Cr!!FmNY&sLtCOG?8(zJv?al(&Cbx4 z1fM=PLtFY7+n+H%LtDb?L)kMIWN1qtCq8RQhPEXA&N(zgTaq$!mu6^78ef*7Egj49 zu%(2yrQ}aQOG4XH@=@Sfc3MN~RPtG-$$+G5*nAC>D(9dT8j(|<&_mjVRdzypBeM55l03Y6BW-++YXp z4k2s~HSTisWUqzB{T;wi8*Sb;L~R3sELA4pPQp5_OqQC^K;Zh z4iYpmM@<|cK5I#inkfCwIW$L2lrnRd=BSApUzVdLp2YId#I)AL%qKwOsae$%R~m5t-uS^#D)h@+H%>j)wmm?NXvZ~cq3I&XE*B5=9kEAx`dw9XW{0i z&5((e7m8F1scYc~3_A{rHyN~rDm#l#vW@Hw{|{K&kr;8SjrT@cp|#c!7l&3GTNz%& z<(^Sd@)Jl(*KO>&Z7z~RR67edeUp?3I)X;gk6!UjL1lIL&M#3??BmlwF2Y6HhokM| zYseD&Xx|pIkG~>U>_Z#K>1a%~Tm~95SL4vGR}xhykzx=&jYeA;ZEwj}uUxjt>F83ICtlH2gk+q1)?ox_oV&yMp@t@vJAWIR zAc^J?TgD@9hQ?XTxgY1~(jYI@Il>Y3*HsJQt3xe2GQjQ-hLTz~ghpqeCbl#+0;P6` zRzb*?`+ej)`(wGH4fqx&ry(gO>e9j;Zhz=S34*8CqQpk0-ZEUg8)R8NTk;d<;CSCQ z9Fi;1zI`flQ*1TGwo+^%#q~rqY1|Gxdp9%8%HvnSE8W>MEb=@KIhLz^6NfEi7)uj8 zZljkbHO&1SDaxvuKwZ@YIfl~s@(6r57QrW_VJ8us?SUfJN3$87%JOAjnY+yBtD!MjruQ{zYv@fj zUBqKBD$@zGUIM1{?WW(P|cl3}d3DS^asDDo2giFHKs1xzt` zPh$`{&So6yijZ^COVocEmiRSfV4t&oBt*yVy%csO)nr6HuDS`-hhy`D4$MUGpd6bs zo`x?#S=*U}u3YW$y)ahGxvonm?Xwl1 z$g=$Xa5c38g(+(*{V^u*K$2hgwlZuvr4U3>^j6kkvJpwzgjMq@s-!ssZF|}vUwQ^9 zyha70TX|J#@T1fkQ#BkK%F8*z)$%fXmQF3o*SxfQ^6s6xehvpe5N}0J!^9&R09e<@}-Y3RTfuVRY8Wo^&0-*rci% ziA){-xq3zAOhitTIJ5mx!c{-PV1)+r$&pG8c4&jS{~4uLp$R)g#74$K+*Z>()YP*j zos8AQ*veglhJFY_^kw%jBo~8eebuwiPTtImExh~dpNxwVi43~|RV8%$FZ8F9@_6a* z7O(mO zxO3GhkVh!Z2O!O*qqH=4eHK29)P+bGe(XYlCeO#ESoo3ru1Q-%)VcC?dq_l@yd2lv zPvI~J?JAF>V%M_~egj|m5vCFsBdsYkJEX?>rYQLI9XJ^4&4~57?O9syi{R89sj{@* zSBM90me#BBWR}*;@WbuMW@){WU(9D|y&7-G(s~6S+mfaAW+|X?ZCP3`o+&reC;w4p9Y>}rzuj0FT$a$=O!rY1cS#UnUQNLc9mjMrGJ1UmBOy~^(36Q z8#%GEPef%C+yl?B%ft&TL&9`>g0X9rPNG+B^uBR)3EnC3b<~sS04|Gu`}1&Ddm`oH zL&c-@O{=MiW}j=8lQmX*xKM$r;1Tk|BMt`9i~#dM&cZo=f^%9puA zwQCIUPz5H?a0UA&JD3Nr^#u@$Da9S)YTX+3B$C<#%67o0OVJY3){yK6j5?Y<{}>u6 z`vIdKXY$)f%3aD)SD>`#oc*qj(#EXP$$zRJp;c?_@jCgscw=kaXP89HCIMb2LS^OO ziNmeW;*iicG)Ya8=_ET!K97S+>9I_|06fE5*}vmJqw%^ez2WS%%Z4*Igt73G4QFn! zVBsel&KlPn&bu?;Y&c84*>Kjl-f$M&Y&dg?f`y-KIIkyeHk@V8&}=wMd9&e6d12uv z8_uK{3qRR#*0|nq7MYt3XTi;ev*2dK`A)WDHk`jp+-x{==Mf7(*>IMA&4#m-F&oYr z*Bj11V|f@&N*hgvw`@{o!HzBN5Q{YIT;8jvKkV7%aJ8_0Cp#Oo+H67f5}L; ziKTSY)6i=M4ptt|NU5e8T$P>UI$^AtJhiOD6+A(z?XHA4THN)Kt z*`DNcWUK{#ST>YH9-Z5{sVJ5>+wg7YzJ_E{#Bv%qwP!M&`Wp`RURUwBHx3^MC-!W( z2;k{UaWF4`xYse`OdQn709W0E9zv?0!GdqZL{0UwKPWW;CF~FhNx}y!a1(SnE;(Z` zN6Jt*$FZ%5B+Fx3d0Nl>wZIeoI3!QNfj4mN7EgNw#Te6-pJob)*jhwPVTiEr$NaY| ziddLzWz%mO%WD<0zYR*_7wt|gbdO?bJhs3O)O*Zmvm5+jkj9C)U{AuY69K=G_^vI= zV=&)hr~SupK8Kl5p9E!=ycP#vITT&$3LMg}fZ2>$)Ja?aK*j^OV!^4Cw(PHv;5a%9 z%zCiKNM3ep|qLGorYu)r$A%&^L^T{-$5q56Z1^6Tb z5}E7-9JL%xoL9qca(f}WMb(<=@-IH6^Cie26yk<9Y;WvR7UC2NaUM9>lg-zAPA6t* z{Upml(80WK=e*yH!!!gTTPfvPWAHQr@oduj4)Fbm*R*>Fqs$@1a}&KwVQ@zi&(HTB z8jYBTc-?{CxhQ`;@%m-nvB>A|;aP=EUOlGoFA;C}ve$_G3yC*g;{5^bTt>Y4TJK`C zFLhfU^sYra-)H`?-+Q;BjNq+*^?!@HQl<^2@C6jQorOlUSpEy>K`3pXVflYW6(K3u z*YfX19pR^AspVgbD-WK^zd}Dq2rHBSVEM~Hj>u$}S1tbtB!u~0-?sd>(W%t=P^0ZXhkis9(?;9= zt4PR|-KW|9-C?mk_Obn|K!LEZ=fSrBGqm;>b~UiFEqm*^d7*u1G)o9pw09(VED5!CJ>Z2MOtC;b$EGR3xO%{$FwY zucIFk{Guxzec;M%NA3OdxLVgxM<@k>yA*~(ys^c$4LMS*a;rjDYM+iN<$@SmF z6&d=E6@+#Ng4Ob?z&apSU{ts|P$T{vA*FP2s8OH86U4J5)7EV_uJ^y6X!FQmPwWiJU zj|tiCneO>FPV-9S9|^f zv?ly)xXbgG;))1l^DjMrFEk+HJ@Hx3pMhf1&*%TNB@Rk@pJcj3Ecs7Q&$M9SX?}*_IF}x7NYh!p_3~!F%tucH=497EhktD{) z@U|G+Qxj&-_w$cEudL3)N zaLT(CQ{FLC^py81%J3mf+iWp&9@o*Pt>Z9iLg!>Vp!)FaIf@QBqV03&uD39!yI>k+~KWZyp++AtNI;qR}IG^C*Q7wpVROs>EYkiNXa!R@P?b0l2U3VP zd^ClLvyj7R+-Wh=Pm;aV#W>iS(Bw7D_#O^vMxTeV`X{Z-Avh<2WPdhJ$qqkomA$Q; zfKhUR@2b0EEY^eUqY#Uc#iw3iEQi)YB;>7&yty~vQf$!NZlmA(WA6SpSV8O7kXg!{X9u*f|)WPXkVxpu&; za1Eq}T)%+}!0}{uIpK!;o9ZXH>Z*Z898HA3WCTLu8JhVwLG+hU!VZy;`Lm#Hmiuis z{V)dhMIbuKr4@LG;B>(6y@tzo@v{Fp9KA7lrRMT#g2N=wxh_O)JP7S4kAOO`Ny}GB zKF)Qu#xbrAYPiYtO~~NZynSU#Z_S(Cnhe`gTpqjpJ)($}@3bNYnWMtnFV!oj+KHBB0Iqb^?^O<;Y|GsShj|Rs2Fa2=6RspODOm~ZaLNxr!wr*VBpPH=NaZV}6yJ91sb%^kVZh?_fd zQr_H=6XBRUa-`TZcjPp#@5oW!p1C6@xVa-IxVa;DBg>dOa^E6u?#S_uqG#^NNx$Zf zoRl$lk z1)65tsWVxI6iA~9!PtkYxfVxmMfu^Q?DZ*XMd}XZ%XFO)wc(FDyXuXYDqj*P_2Ip= zDx-or%YiCS17gj!JtvD@B4;jA?tVC=m8YGeucJu&NR~Shh7Mi z4Y8DSde}%(nwqJS^u3;*sZ*B%x2K{%+^;V8_ZgM=CXDD#wsVE&T!)3U^HA7Mol4FH zp5jDeJ?`Q2;PI*jm7*qF&v{Ny#RcpyhZnw!3p(+<=d6q;u0q0|VWIxDcnjzDiUw?z zmU~y$*vw;N(M9GS9Iec4INF&It>i;-QUu4~d*~hwDUgg2{N7Oy={nkD96)cY(`~%qCz4kLm z7LG>qAJu8zT`c?@=`SH@wSFSxG~A6$>psC7m%&6@{|MpVhbd?)5$1dgh-w=1Cp=AE z6n?y+E7|{J7R8*u0I{3!^O*Gns^t$tXTr~8){P)5KNV@==P@e-Bh43(7J47Ee5T(( zA>rpSD~a|%f9HK<=Rd6*H9PYBg7rJ=&pMfuFyR^NEuD1xpeNR|7InYy7cjRGL|Bho zROP}bG{ix)ek-&V4n$g}Ba`O1NQm`o%R#ZiP2h!pmPtR0^gFTkPQV=WH(q~2xAzXx z@)-3~7FDQlIplHATtA zHj!n4PwjDEIHd0c1jid#{uwKerW*E41OJV*)i8m$?xz{=ZYt|$+qwx)KQvc%sEb}V?y<*Cj`%Ie<(V^sGW=Csx{pX38heyT4MI!TnaFsT^ zWh558M6L|r@1Q5cKhXi@BJv#|0g-PMvC~4N17o%ak+q%JA_g+@kpIGS?NHIR%$5i<1%_wcF~DGME|(bwVdNpiIwPU}-d9tCnLkYQAiM@U4@N{%S~ANJk^FsdSX{C_VmZ%9J8 zLJ|_L47oUj1kiPL4JraE5rO59a2Y}li00a40w{tC;(>}$T-OuCbJeKewTiNcxQh3+ z;w}0Wyw?Nob=Ci;s^6O#AV56!>+bjWXV;nP>gww1>ZLTw@oKy4!9tTwha z2S(>ht2;qo0AO_!trYN-`*i6e)Apr%6N0dGbEpqWJ<|QQZ?tsRQ{zjg*0K=<=407e zwxhhafjCVN+3+5yDxV08Fz+7m5#Cqqz89FO;fAm$1pH_ZE9Sge%t)*}KO z2JpN93jy8)$oK%dOz6H5>& z+P>P3QKNBePuthP+ZfF^(Yz0$-se4(^d-W(iW;A{#>^W)942DiV7Kp446Ve>u9pDl zP}0SB2RhU@+p#@9_IY&^^o3jjQ(m{&-EwU0Ls@PCUnneB!?GP2lZN5*08~v!07Q5n zDZFok_r0K%N5;QcDDVnvlxTeoS=P}2%DGLfZ4D95cfVLgtxL92jV1QbwildOj^j0qr%7{sC;2> z+=1ckQ4Cesr$DH%H+sT;4B~X@;ij-6HhDeh0@Rzd!ROtvf3&bg)M)Ty9Z2~Gi5f{P zQsj4}ulmRXHu}I;A86ybDRJo*!h0d-9F|{)=I6~<^v9^nqrRhfm?jwWdR1m@CP4rJK;fzxA?#} zJ}|}hs!sQTg*c}Y-{J%FaW@t94go~MtML#8p7epQ1dsvt!FIK3s1ID_15f(EA8}r# z+28}O_&@;*iB`?_fzy59bstDzK~S1(A6Vi8YkXi1MyoVe`M`ZX&;t|Fs*ygh$_JkC zfp>i1aI8j&>jA7>d6m_jBs+`VvYoIJU+1&W`+UgZ4R2HT)q8ku&wg?bPqKUtZ~DnS zJlWoGc+*er;TbrtpIpf7Ou*srrk~uyQ+#H}esT}bz{&mO9-hLvY5n9Lo@^R8yy++R z@P-K7C)iIfWD49jyPsV6Qu=;<`pG>!$^6}Y`uCG~DI#-t(@&m!8+gBdat}|&!{JRo zxre9leg*yH9-hJn9NJGVryMUd2M+Hi_weROU%xxDpM-7MnQ(a1PwwGqzlTidC-?A_ z=Fp;kau3hIGy2IrJo!ZvIlM_s1K1}@lYOKt(yC^A3$NZ%WdFNk%_NFm&Dm02BGuA` zX{eC!wc5$9T25zTA51)*`u=j~Vn7LlXIakEUEw97hCJP5T_XFzJl%g?V)~UckNl5? z{Bhm1*aMaDar+G@lXYkHgjR61pPmM7gWkSbKs)a}g=KeVxGOr3zdZ$o3O6XJJ@D;O z)VD{d6>Sarz4U!g6v;gi5=9SwK)Sj2Xu7cmJuS?i6z1OGp47o9$&*ttmpsCWN0F!d zJ|)i6Eu;Z;cce@JiTtUFZxL>?k=2cv;3j*zAK96brXsI7@naS0sD+j{^1)tkUjl zh~E}sYu~{$Fgp8l8UNth@g@6fk$&G~ouCvmeH57t%Raz90kY(~M3@7$%A-5TULwqV zy5UiDBa|+=E{it9ZP7vUdR)pj_@z+$gW6tr_FqtLq4C^*p? z@S@1P!ZVx|YB+kCZG|*2hO@d^bhXWiQ4DxuMaBqjAgEz!2C9kRa}1b$(}3yOLfdjJ+YZ92R123GW69Q z7-g3s;6e6E-C|uuueuEy z1t9rLkNW{+C)AT8tT*`9bR*c&HhX(=^w%MX)b-;a$_kVMSh@1Fsm(DW zXK$6bH^bmh<589eLUC!i@nlZrBKB9R~GSg@3FG$*ZFGb!UMK&57$wE%Oty$KqR!^gY0TM7*a0-Q$0oQ=h%0{1pHah$ zw%siub_;WL7@D;C6K}Ea`h^||v9XV%u8C69Ga)YaQ@@arkQiGB*(|9ECUlJDdYIK9 zg=|DL#(Ot2a5C`1L>gQi z7k?UXLID%l(Qb;1-9||{pC4z%PGH&4!Ub`$v684rJ9~LtTgQ+-n38Bk|R zi@(Gt$c_Gv_RaCJ*And1GHyXA`Oykh!L0}-dE4KFd^JMd>(ADj_(ZN5fo>mj5jeT@ zY#)h{jZf8D7~dPkyz_+5Gdz>5OR2G|PF^;13y z>0vVR+s7q4-TcD$LU!o@_E`cv1ke`#>01H10C4ErpRa?GZ0l5MdRDU2wHm~IAZ80< zX|mJf^gPR21hU8L0LuWZwoNkHKmd5=at%NS#4hvpiEyIRnD}LU;}p@>hZEwn_+(PpOi(MN{Rz_jSU&i!x6w zqO1v9YLa$-8fEPx+4QlRvi9MX;|B9Iinw8?B+1OA?nhZOLQ|h1r>}OJ5j>6_aS1E6 zKTP-=^4vU456I_1Ebl~9FGG}nY1gCwjtX#0a16v`4R=0)O zwsjRiNbt7~ZtQu!+AVMEF=S$GZgk5ww2+;9s}>A(u&fNGke4{;*U8?`Ld(7t}GJ zo(BlN0p?A?ECTaC0=x_G5kR+V;E_Tr`9lQh_ksNuzzT}}$8`cVh0G!zpxY;2XDNcc z5Ns*{_;-5Q)-?dxg5Nk4vk~gwXPQYIB-l5>4i=!@VVDj8|AJ}saI_!~1S!uK(s3K72K}0#p@HRK6IfoStbykc_3D)25atNszo^iNGt>(ib=1gXri=v=*N)KUaNYn~WJ&FF zpx0o6HptLk#wl5b_AuTsO8Xe8_*0o&W=?9GK7m%rNyxhpASl440R06x9Je|EKo^c4 z+?^nwwd|)mINkh$E*uX)uM5W;0J?A_($Fj%-8RrnV)bxgxD5OV04sPT-H#L0T2SK! z=z9=nivpYna6CZQ-!d^Bc>Ql5={3G%-}F*yrpat|`yJ?W0p?1Bn?TP4=r#uIJg}z; z_64v@0Kg}9v#nzQmJ429Ubqk-I0F%`6ihytRZ3}fyAe|KZmpJzqp4UUz&rqXa@p-U zs>R_R5$x4q9|eGy+)I5?a8H4I2_Q|@$BRYBi#x{mxPkf6wb4GfsvjJ`qApQ{Z04TjTz(ax=5Adu2Cj-1Lz)b+}1E|)7-yr0& zFY3@GeE@7!Ybw*-BGWY;oE~dHt4vD(biIBJKxOK7UWTnXxIC3ee+16*R3i8yP5&Sm z=TLq-On_bh3k5g?U@3srt$)yX;5C$bT`P4rQMU#_t1_TjC)nv=|0>u6Xn@?hGfkfl z_A#*Fo542$yd>0*fPYzlF96<_X3tSgdSY!CY(4?wGXRz^qKkN-#HjS6LE93n^q=N1 zk9N1MB^2%BJGN1Md?Na&VUB+9&ruG5>SKXFN8QTcI-FK9qUqz|>Im>BfDR%$xn5_w zITZ6#;IBULmJbZYZnUb{2X6C$`vlmZ=C9C+m3f`yndQlLhUCt6RkEbd8Gq>lTx;O# z>Crwhc_Mf#>wHpCJ1a2&tQl$$k8}6 zq=V!qIV!>sO{X)~iFF^Mu$A?Z^w_M4tk->WC+R*%Gu~#+c#SBxYNnW>hj}-5ItocL z#=zS4V2yCM7qpxg}={>AsWM^o0_pm30 zW_6}uI`&}A<{nlZz=4HTG!XwLl4s?KNt8$HuslkK!ft*AMK!r8ODttHD0KG^r-Q?(usrwXqm?(JL4<~Qw<%( zpW&!xeuhpGvpVe^<@nOEEI83ld~3w$bAS2^x>)NVgXx=-^d^MM6c|Os46dZ zb}+G1ePlxi3EG}hm_k%hO;3Hq=cYcgp@VojtvnH=Bl9m;&QoI(#6@~%V>eb@KA&=I z8Z)hpIX3OnA(s?2HgcqMy0}$k2S*wibEwn_PIf2oQ)yy+m8!_(F_hI9z4ANby+fPh zRBNeO)T@GzSEZy@Q!W?fSDEsilvT4eru-)5cf}TKjn;JrsufNdCqg2BhFaD#|BGux=h$JbsR|b zW?v?(bPAW~6o!$hBWbEDsHsR@PFbU}M|c>m(U+Aviwdt#otu;*YL1l}m&9D7^%9-K zlqM?lpy3cIbfq;FveBXH25B>$Dvjbx)N)b=_A#5s)cI8C3hc>qJ!OsRrc7JTQZ9~z z#dFlC?JhM+_>{5==#@K6@|3dF*lzFgl3D`KfpL>Q?yZAeq+(^REfYNQh)NqbWV zP@y)yRBb$^Hx*2qm3kBvx(JvGhI%bk=SkHikt(tM)W1}9ju7ilno8&z0jbsRNIsVaB2FSV zZ0*XV~Xf=uJWdU)%QZZqopfU?^4 zZBgnobym+$gpJ08+l=}Iks5!br#^#BEupOHGv$3{pPpDA!{Xm7r15?4U56kn^*JGrozT1rY)=`PteWnbFSZo_8 zms6wh;IC%zYbdLLUim4?s*Jyyv(=RI^KI)8EHGn*ER7GS5)?_0vdkK{QAtw|D)gk= zXrz;t8V~*fPmKo~^|UzU3Mw=nw55f{15?3JlLwlMZK;zZRT>YLP}X>`(MWI*go9=1 z>#5Lquu=7BrJO*GcKaGNIyBQnl|T~IAgkVHbz(bbdAt^+iyXOwN`)@H=g44RG_ZR8 z3);Zy_#E7;VC6r?Y=oP0lthI7@kjC8K1+(iLPCuB0drFxP|%$_;Iy7iYiCqqkKT}v z1ed3T6M@o4fGQE77N8oyJX`5H2!2^|Ro%WIS-P@wF2j4gLXJLHxj;9{87k{?CCbjw z?D$Gk@q!F!Rk9?H8M+a^I$1J5_8?NxtQFZAE2WP+&7Payv*c|%`+c<(+hhpuCuzc{ zLC;i}&FuTCtDFx+haV(qs;8Vk)W#V(g8zrnr$)Yy6z`?6UucE51Nus9M6|m_w3yB+ z^G6aHZT)*q8TSfo{&o{odPvw-YqCuUXy+AlL)F2wKS31MQDNFBhcqQ)nAsW63CCVu zGtI2Cyn-4~w&9_Ek=r|@cna)iMoD#6x~RR2uKc!{zb+<)S935GX8yWp@{}W2 z;Ao)pr*oF3_-vE6_tHW}yl~Al@DHN7ECatP^OdbBqi0vWwQG}M_faCG(my|WY*Ze4 zy!g-<^`QetajGfHvMFAMALxU|G2&ABpW_liqL?!_vBxAR?H)XadkdO&0O^atqyq%& zAjuKTfk$&~Lx6IC!BVxFszSkh_In=70Hn?4<7=?^Kw6LUp*s~sk97d&0t~b-;WPa` ziq{IzgXL$90ObJd0J`=e*2r%a1R0sVJ*oXbd$L5IZh|`u;1Qu-4E3V`ip>MNS+KW) zeF4D34+hvK_*cNcEkK(wocjrZwz~qLZBNkqf=&cb>^Wf51p9li={{C?h;dz=6nV|hRWVCp43zFg2lKwlxi82~o`XmeTb z?iB1)uy+9%^sxXN1^o@^2LY5=RyuhbAbnrv;TZrAe*?f4!T%oo8$PwHaUTk{7VJL+ zxD4PM0RAbzeF3_l_w}xv8^yzCjBUj`H-VMiHr( z&_vTcgG~LDvW6y8PLUC6zVf$7UnXCXnrWykb>b8+U&+&R8f~Ka%6U|1zG5m^#HEc) zWa`aSX(-Fn(;=;pxlDbG3e8tMdA_2oq0N*b@z~%AUbc6zIVMvck!ZXipHS)ihD<8D#3;D695NS^HA$ zT@vYQC++n`hnnq;-AQ|;RH*h$MYQ%#rAoCob|>wvq(Zgl$@3Ux)t)Jf@~obHMDnkw zQS+E;2A?wBQ-)XGm$E8jsyXHBd1zPxQ;h-?5k-=^k|rwE)QD8#Z>hhhLbJT7ditkY zVWF^6YpKvI&r~4W43TYJ1TDcm^*zd(;hCPb{Xx<`>sT+tn`%x7wWg2M45dar!!!ZK zns-@yymMQ!J}St$vz-<~SZYoh>oyhAnaP<9Y;vZzy_`vNh7U~6q*q|y_p+gQS)X+2 znny2wD7b>oobl3^*-}=^+BIIf>wg3RN4j*z`{`he{yiI0&hHM*kyx!WA+bl_@s`z> za*uM_{6>I}0fqvkuLL+6AUGS^$$}|`c!~h$0aO4)$Fr>7UeM|zPIxmz^(Acagd)&XUR{SEEE#&4HRlB&QU6S9MG|^%Cket^8eQQGsediB41Zg;1{%A8gW(p7Pu5 zsnuXL@jOzosn=81Y~CHb7Kpz#OIdidNhxeBYu%Ph*QZ&l188Gq^>Wfb>&M z`5?-gTTa)8sk0~#z|!nvO^?edYg$xnt1fI%6vV_&J1jfCj(aglw6pO1}2G8jq#|pO!BS8UMY#a zqviZ2?EMt6mZC)2FIX$D0AY3xHvmOR7q)<`$7KMMQ-q(Lqcb=;MLNqi56P#d=t;5> zYhaXEPtwkrD$V*oGTuwarb@#BIem?F^@Q4)=1DRbr2Kn-Y(idH`OL;y#SNkQ6RlbG zB{hvzCH0k|6Rgsb`uY>B;^Mg_4VA@pC862H_2t#IbIOa$D(lNjL$z92SzT9KA1W_1 zR1GyHbq%v?L&bIV<#Q^_=MLI!y9m?L`tp*{Phh%ByRydW>Juz_uBe=c*cEdbMfp6d zvZjpB^7{JfhFO(m^Q?;MQ1LvAanx5-)y}nM^Iv7{9IK>$)*MTl%n=@QtTOp;jz9i} z+S210f2kn$Hs%Pe^wnsEYRcz@tWafjd2M6Js;w%ksH|@Y!Dwb8ckX15MKE3(SK1xbU%j*QyDv3O58C-EiZEeV!RZiz2tGZ;K zmh0-J6z)_CbtU!Xe3euoN3}rJ4dt^GC@Cu|_TlQ1sw%|xi2PlsU0CSBDG41x~eHxm6tSxW&+fd&n>R4 zsAwn;S@rzq>8PSi?64tJ5-Mh>tx(f04I)-rQCan4IH|kr)JB=n#ZKtG&E2jo!i>s( z+}J9j>6Hma-_T0kM3F|CHdLNi{-YQ~H&8a^HFGNKYio+5EB2<5mf9$@Qp`}iw6!Ab zOuvIftuq;47DKHJ2%%-Q)vZ+P4#ZJ|kZ93L7G@Mt4bVbKRk;N8#?laBJY>z(AXtod z;w^vvCeS0zH-*=P(y@;a;%?QQB{J?hN&tVLOWrqYHN>gtlJ4$U%L}x=Q2Z6J?=!- zP`MMLerH5O*-jJ{VT4(8mex7O`5FnGoJ`XSPH%II=Q*^XsZ zP-R(^HlpBCRCu zi%VyhmmW`V>V+28VgTdcByklbja8xIYJ>HB(;{jV8%&D|^Q-o@5XsVPqL!%H>!QH} zNSY0%-UrD1JXFoh6GG)?y7}^C*fD1ijv5A9F>kwXsVho!2JvIqw%iVbFGJ{>3h}Y1 z$to^wtoPDvLY_tZst>1&L_Q0ftr@4LFZTG6Gm8*7dis5%Uh2@>TbQV2}7sJ(K{E>6AEyHMBx|Uc?e&WGbCqZ?P{ZuP2kNE|GO}N3J!E z)ic8=HDGNYMz_Ld$f*qz&0mX=v)-CnAIZFBEui;5SQYi<^XeqIm5+I$I;*~9ZbO~u zh0jv{E8)L7r3ls58TS%E3{Ade`%$4q$m>kJU@cLHly4l(qza}mdv2*z*2*l_)WWV- zIn?@E)jdbU?8Z=8?cADHD0WBsP>tm!OBT^0R8lG_hjdk2@4*Cs(#i(GR@ca~XO&ek zA-eehq*3L(;xK}MT3%mMQWe3dPZi7B6e<_-)b`aiB&@VT-U#j7(uOLUU{>B&jeHZP zi@om#1!UWyjRY%74;N-ORwzYXeQ`|*nVhnzEY?*)U}8cTt{OBDM732_ZB^s(7K*91 zO5~$qHv1fzt!m-U(5<>E#!**aJ4?A%G*(I3ck)oV7F%I~N=YJU_HFV_a$wcZDXC)C z}X4YH?8 zJTkB1<3EN+6n%urk73}^RyCBD%V5lo)hqb3;1%~aD58c($x>C^SR-2(OA`?o3rZ28 z`bySaS>mjj$6Lo)mAcgxg@o-5D5&cugfF-X6YqHufgvnU#vOLM5}TYDq(7NwYXFs?Y-yPB1EbuU43CP*sgWVgD48 zhLSlP5eYP>p+@! zcddGb|KL^4B}f|3M^slUB;IDASz6Yildua%>hzo7Z2*qPKu6qA)+P1mviMaJ`DT~<7|9V$9Xd7uvD5fpV?)VI*$s*ybtSVrsI;V}SPt`| z;;mXVu2WoE zTVGBxt4B(*e-h`{@5mzIPp}42G7naxR_G_1T9z9UaKgkKvIRXT&;vbG z*qiJTT0|=&?8_&HQY_yZp+d8n0twyZ46GZGqS$3a+3~;2&!4mJ;JktPa`0T1-*7@h zJtwO9QaeCW*#SV+`wo_`%DO=VlxhIe%7J=*Lw)I92$kF_oek^o@L0OaichF42US@n zLApXe**2-Qw5~a8zY(Xpv7t&O(PbdtU$x{|>8Em%w)w4Rus7v|L|ggABYuWNW^8(< z^iv0Aq96M4PG^29A0vwK=AX(#cc}UP9_6QUp*=}%zS;G+j!E=X`9^1)Qta5o2(!Cy z9lu?zu$tsPUw8hk;}9)T3!Y^(rJt?ix2r^+n#`v7XEK2o``@3>L^k9*w+CCtDZZUxt;Y)O%&+=R&&j`h~r=k{~-^SHtxW z8#LGWsZ6SQP*5p{i1~9%>q%B-{t}ElJ~cDR0hKA=g0r|!!KeSI$RfX#9L+x%w2EhO zC9Al2=(ur1Cl`+&Ib}rggvnz^6c@{xlF^0INo3<6iqnH1BZ?p zH)81U$=1Z9hZb2QZIcCJT!$*yoq=lm4BaZQy2{hakCX76K#PBf_Cuq%xqLJeb zGDCk|BCEBPNU+G2SZ-8#8)zq(ncH$0Oi)6>8*YIyiz_Qye|!u!)BoaNHOV7&>g& zi1BnfcEUKXi3gy;;UhiVc!4GkJ94O39yNZ%@0EJ^i18DK4j(>l9Oa_1ilQAuwWu8aetXMIIf7 zhGQF}3{plmW|$9;0xGmdD*5j2I_V<@Gau>@W`+Q8Z#0zr|NlH<(yaTUYXj#`3{( z1B%CKgTk0W*04cBKhUcgsOPr>tziQ>Z!Y0*n5%H|)Fn80aNeN2fx7!2z;-U!XLcx5 z*TA6xkDX^X5)Nyt^Q)@nm=DecYLDX@U~s?v_T8_nWaiBM4?J+t{s;VK|Ct92-mhZ+ z14{PaZ|1aevX0 ztB*f&OX=lhyoe>26|Zd z$|QS2D0!nq^jS9unAe`H*ci8y*ppn*cKEBz&a4%#6W~`0)wXu1&8Bv$*7k0pHlNzD zT06Lf+JmSauC)iZQ2Ve&_1!0H{i9Y31kVe>jVG@a(OztcfWKf7a8{hWSqQeaMDURi zoNqXMx)VVUe2E{#hv1Bp7YT=d?nI!X`-Mud*lt0{vxVUEg*u955rQ^h^MJFVtcFxg~-%Lhz*Fa7Rl7>xAF|LvVjf1WyRTZ3|UVPq#$yiV$40 zaJ%SXOG^ar3c;UD7az1l@P!a8HKX{tC4$8<5d3R_c5y~b3z}>af{z!dkjq;lSSbW= z7!FrQ2qLO(7J}y%=v>?qAy8EhkmEh)G0ly;Rx!YSYw%h66fOFY`3eomEEzxWjnim4jIYyE%TcSD5 zF*N@?NiF9rC#=%+a9b?1@u!|$DB5YR*!5j0{%&uyQpjil>o3Hb7 zYfA+83c-W(sz9DR4DG8uQT#wgdk#wF9^Y(jSw$+1gA)7H@_46bCZx3PGvZf zVav+$;+@?_(h}8WTcgU0RfZxy_nw%vaJIhdeprs4`7sxBUaWoEV&9W-wzL?VURqiKWj7k%;~4)_QqxxF*Sk8EFZ z0=HYhNvsGsp|~VBKH#(qQWbLBhur>wA~y%HYk|{1ofU982i=&WLbqKY-wgzuIn8eT z6hpi_K1D6?Y3HOAN%O|gPi&qVl;)wI);poOOHjkmHyU!9emTSaSs}+Syd^^@15Ng`Nj)^%@5lF1o?I<>z^g26g%&XZ=2x6GC+zx?e=fpxcF;MN=PFpOt zCg>&voVG=7*T5le3YMBs!ge;a(e{woH;5*BI1S?A#5Sv z$db63?*hN3K53ECx6|2zx=t1d7y2?$%(q z^q2F8|9=&)tmW^%{Pq=1;)m@nqzDB+}YN-dtB#E-sk`C z%ANmi;V^pEX*DU>u6fxm>sbpKH8d=9F)Nwba7Mk^�@Zi?wF)`IF`|`XWIK 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 ny6 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 JL$(Qi{}%uqBv$|c literal 0 HcmV?d00001 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/settings.gradle.kts b/settings.gradle.kts index cbfb34c6..ab4af061 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,5 +18,6 @@ rootProject.name = "termlib" include(":lib-intf") include(":lib-native") +include(":lib-wasm") include(":lib") include(":test-app") From 4299d329ffe859ea08cecfdc688a696e15a59c6b Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 18 Apr 2026 21:43:12 -0700 Subject: [PATCH 3/5] feat: add benchmark code Determine how much slower the wasm backend from the native code. --- benchmark/build.gradle.kts | 39 ++++ .../benchmark/TerminalBackendBenchmark.kt | 207 ++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + 5 files changed, 250 insertions(+) create mode 100644 benchmark/build.gradle.kts create mode 100644 benchmark/src/jmh/kotlin/org/connectbot/terminal/benchmark/TerminalBackendBenchmark.kt 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.gradle.kts b/build.gradle.kts index 1136809d..1bdcd604 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.release) 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 91c7291d..87a0380c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ mockk = "1.14.9" junitClassic = "4.13.2" robolectric = "4.16.1" +jmh = "0.7.3" ui = "1.11.0" [libraries] @@ -65,3 +66,4 @@ 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/settings.gradle.kts b/settings.gradle.kts index ab4af061..874b3a8b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,4 +20,5 @@ include(":lib-intf") include(":lib-native") include(":lib-wasm") include(":lib") +include(":benchmark") include(":test-app") From c34888b7185f347db12bd3ad2af2802ec1be6957 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 25 Apr 2026 11:55:14 -0700 Subject: [PATCH 4/5] chore: add method for host-based testing This allows people to use libterm for Robolectric testing without a lot of native code setup that is annoying. Should run fast enough for that purpose. --- README.md | 37 +++ build-logic/build.gradle.kts | 12 + build-logic/settings.gradle.kts | 1 + .../main/kotlin/termlib-publish.gradle.kts | 36 +++ build.gradle.kts | 1 + lib-intf/build.gradle.kts | 11 + lib-native/build.gradle.kts | 13 +- lib-wasm/build.gradle.kts | 17 +- .../terminal/wasm/ShadowTerminalNative.kt | 213 ++++++++++++++++++ lib/build.gradle.kts | 29 +-- settings.gradle.kts | 1 + 11 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build-logic/src/main/kotlin/termlib-publish.gradle.kts create mode 100644 lib-wasm/src/main/kotlin/org/connectbot/terminal/wasm/ShadowTerminalNative.kt 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/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 1bdcd604..dd5b6957 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { 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 } diff --git a/lib-intf/build.gradle.kts b/lib-intf/build.gradle.kts index bf3f9046..a0a01785 100644 --- a/lib-intf/build.gradle.kts +++ b/lib-intf/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("java-library") alias(libs.plugins.kotlin.jvm) + id("termlib-publish") } java { @@ -15,3 +16,13 @@ kotlin { 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-native/build.gradle.kts b/lib-native/build.gradle.kts index 601ddcbd..0237561d 100644 --- a/lib-native/build.gradle.kts +++ b/lib-native/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("java-library") alias(libs.plugins.kotlin.jvm) + id("termlib-publish") } java { @@ -53,8 +54,18 @@ tasks.withType { } dependencies { - api(project(":lib-intf")) + 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-wasm/build.gradle.kts b/lib-wasm/build.gradle.kts index fef74815..d4aa22c1 100644 --- a/lib-wasm/build.gradle.kts +++ b/lib-wasm/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("java-library") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.wasm2class) + id("termlib-publish") } java { @@ -88,9 +89,13 @@ wasm2class { } dependencies { - api(project(":lib-intf")) + 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) @@ -99,3 +104,13 @@ dependencies { // 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/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/build.gradle.kts b/lib/build.gradle.kts index 9a7706bc..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) } @@ -102,7 +101,8 @@ kotlin { } dependencies { - api(project(":lib-native")) + implementation(project(":lib-intf")) + implementation(project(":lib-native")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) @@ -155,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/settings.gradle.kts b/settings.gradle.kts index 874b3a8b..1cebde44 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + includeBuild("build-logic") } dependencyResolutionManagement { From 9068c4b45bec9d6d614943cdbce43261037ef5d9 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Sat, 25 Apr 2026 12:41:44 -0700 Subject: [PATCH 5/5] Fix deprecation warnings for UI tests --- .../java/org/connectbot/terminal/AccessibilityOverlayTest.kt | 2 +- lib/src/test/java/org/connectbot/terminal/ReviewModeTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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