Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ The Android terminal renderer vendors upstream `libghostty-vt` shared libraries

Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when
updating `Vendor/libghostty-vt`.

## MesloLGS NF (Android terminal font)

- Files: `android/src/main/assets/fonts/MesloLGS-NF-{Regular,Bold}.ttf`
- Source: https://github.com/romkatv/powerlevel10k-media (Meslo LG patched with Nerd Fonts glyphs)
- Upstream: Meslo LG by André Berg (customization of Apple's Menlo), Nerd Fonts patcher
- License: Apache License 2.0
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ std::vector<uint8_t> DrainResponses(Session* session) {
return responses;
}

bool ViewportGridRef(Session* session, jint x, jint y, GhosttyGridRef* out) {
*out = GhosttyGridRef{};
out->size = sizeof(*out);
GhosttyPoint point{};
point.tag = GHOSTTY_POINT_TAG_VIEWPORT;
point.value.coordinate.x = static_cast<uint16_t>(std::max<jint>(x, 0));
point.value.coordinate.y = static_cast<uint32_t>(std::max<jint>(y, 0));
return ghostty_terminal_grid_ref(session->terminal, point, out) == GHOSTTY_SUCCESS;
}

uint16_t StyleFlags(const GhosttyStyle& style, bool selected) {
uint16_t flags = 0;
if (style.bold) flags |= kBold;
Expand Down Expand Up @@ -272,6 +282,96 @@ Java_expo_modules_t3terminal_GhosttyBridge_nativeSetTheme(
ApplyTheme(session, foreground, background, cursor, env, palette);
}

extern "C" JNIEXPORT jboolean JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectWordAt(JNIEnv*, jclass,
jlong handle, jint x,
jint y) {
auto* session = FromHandle(handle);
if (session == nullptr) return JNI_FALSE;
std::lock_guard<std::mutex> lock(session->mutex);
GhosttyTerminalSelectWordOptions options{};
options.size = sizeof(options);
if (!ViewportGridRef(session, x, y, &options.ref)) return JNI_FALSE;
GhosttySelection selection{};
selection.size = sizeof(selection);
if (ghostty_terminal_select_word(session->terminal, &options, &selection) !=
GHOSTTY_SUCCESS) {
return JNI_FALSE;
}
return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION,
&selection) == GHOSTTY_SUCCESS
? JNI_TRUE
: JNI_FALSE;
}

extern "C" JNIEXPORT void JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeExtendSelection(
JNIEnv*, jclass, jlong handle, jint anchor_x, jint anchor_y, jint x, jint y) {
auto* session = FromHandle(handle);
if (session == nullptr) return;
std::lock_guard<std::mutex> lock(session->mutex);
GhosttySelection selection{};
selection.size = sizeof(selection);
if (!ViewportGridRef(session, anchor_x, anchor_y, &selection.start)) return;
if (!ViewportGridRef(session, x, y, &selection.end)) return;
ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
}

extern "C" JNIEXPORT jboolean JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectAll(JNIEnv*, jclass,
jlong handle) {
auto* session = FromHandle(handle);
if (session == nullptr) return JNI_FALSE;
std::lock_guard<std::mutex> lock(session->mutex);
GhosttySelection selection{};
selection.size = sizeof(selection);
if (ghostty_terminal_select_all(session->terminal, &selection) != GHOSTTY_SUCCESS) {
return JNI_FALSE;
}
return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION,
&selection) == GHOSTTY_SUCCESS
? JNI_TRUE
: JNI_FALSE;
}

extern "C" JNIEXPORT void JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeClearSelection(JNIEnv*, jclass,
jlong handle) {
auto* session = FromHandle(handle);
if (session == nullptr) return;
std::lock_guard<std::mutex> lock(session->mutex);
ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, nullptr);
}

// Returns the active selection as UTF-8 bytes (soft-wrapped lines unwrapped,
// trailing whitespace trimmed), or null when there is no selection.
extern "C" JNIEXPORT jbyteArray JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeGetSelectionText(JNIEnv* env, jclass,
jlong handle) {
auto* session = FromHandle(handle);
if (session == nullptr) return nullptr;
std::lock_guard<std::mutex> lock(session->mutex);
GhosttyTerminalSelectionFormatOptions options{};
options.size = sizeof(options);
options.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
options.unwrap = true;
options.trim = true;
uint8_t* bytes = nullptr;
size_t len = 0;
if (ghostty_terminal_selection_format_alloc(session->terminal, nullptr, options,
&bytes, &len) != GHOSTTY_SUCCESS ||
bytes == nullptr) {
return nullptr;
}
auto result = env->NewByteArray(static_cast<jsize>(len));
if (result != nullptr && len > 0) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(len),
reinterpret_cast<const jbyte*>(bytes));
}
ghostty_free(nullptr, bytes, len);
return result;
}

extern "C" JNIEXPORT jbyteArray JNICALL
Java_expo_modules_t3terminal_GhosttyBridge_nativeSnapshot(JNIEnv* env, jclass,
jlong handle) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,21 @@ internal object GhosttyBridge {
)

@JvmStatic external fun nativeSnapshot(handle: Long): ByteArray

@JvmStatic external fun nativeSelectWordAt(handle: Long, col: Int, row: Int): Boolean

@JvmStatic
external fun nativeExtendSelection(
handle: Long,
anchorCol: Int,
anchorRow: Int,
col: Int,
row: Int
)

@JvmStatic external fun nativeSelectAll(handle: Long): Boolean

@JvmStatic external fun nativeClearSelection(handle: Long)

@JvmStatic external fun nativeGetSelectionText(handle: Long): ByteArray?
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
}
}
terminalCanvas.onCellMetricsChanged = { emitResize() }
terminalCanvas.selectionDelegate = object : TerminalSelectionDelegate {
override fun selectWordAt(col: Int, row: Int): Boolean {
if (terminalHandle == 0L) return false
val selected = GhosttyBridge.nativeSelectWordAt(terminalHandle, col, row)
if (selected) renderSnapshot()
return selected
}

override fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) {
if (terminalHandle == 0L) return
GhosttyBridge.nativeExtendSelection(terminalHandle, anchorCol, anchorRow, col, row)
renderSnapshot()
}

override fun selectAll(): Boolean {
if (terminalHandle == 0L) return false
val selected = GhosttyBridge.nativeSelectAll(terminalHandle)
if (selected) renderSnapshot()
return selected
}

override fun clearSelection() {
if (terminalHandle == 0L) return
GhosttyBridge.nativeClearSelection(terminalHandle)
renderSnapshot()
}

override fun selectionText(): String? =
if (terminalHandle == 0L) {
null
} else {
GhosttyBridge.nativeGetSelectionText(terminalHandle)?.let { String(it, Charsets.UTF_8) }
}
}

configureInputView()
container.addView(
Expand Down Expand Up @@ -147,6 +181,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
terminalCanvas.onScrollRows = null
terminalCanvas.onRequestKeyboard = null
terminalCanvas.onCellMetricsChanged = null
terminalCanvas.selectionDelegate = null
destroyTerminal()
}

Expand Down Expand Up @@ -174,7 +209,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
event.action == KeyEvent.ACTION_DOWN
val isEnter = isImeSend || isHardwareEnter
if (isEnter) {
onInput(mapOf("data" to "\n"))
// Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline).
onInput(mapOf("data" to "\r"))
true
} else {
false
Expand Down Expand Up @@ -288,6 +324,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
GhosttyBridge.nativeDestroy(terminalHandle)
terminalHandle = 0L
fedBuffer = ""
terminalCanvas.resetSelectionState()
}

private fun feedPendingBuffer() {
Expand All @@ -299,6 +336,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
val suffix = initialBuffer.substring(fedBuffer.length)
if (suffix.isNotEmpty()) {
emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8)))
// New output invalidates an active selection (matches the web drawer);
// otherwise the copy toolbar drifts out of sync with the grid.
if (terminalCanvas.hasActiveSelection()) {
GhosttyBridge.nativeClearSelection(terminalHandle)
terminalCanvas.resetSelectionState()
}
}
fedBuffer = initialBuffer
renderSnapshot()
Expand Down
Loading
Loading