Skip to content
Open
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
30 changes: 30 additions & 0 deletions lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@ Terminal(
```
PTY/SSH → TerminalEmulator.writeInput() → libvterm → Callbacks → TerminalEmulator → Terminal
Keyboard → TerminalEmulator.dispatchKey() → libvterm → onKeyboardInput() → PTY/SSH
Mouse → TerminalEmulator.scrollWheel() → libvterm → onKeyboardInput() → PTY/SSH
```

Mouse reports are only emitted once the running application asks for them with
DECSET 1000/1002/1003; check `TerminalEmulator.mouseTracking` to know whether a
gesture belongs to the application or to the terminal's own scrollback. It is
Compose state, so reading it in a composable subscribes to it.

While tracking is on, `Terminal` routes a tap to the application as a click and a
scroll as wheel detents. Long-press selection stays local — it remains the way to
copy text out of a full-screen application.

The public surface is `mouseClick` and `scrollWheel` only — a click is always
delivered with its release, and there is no way to report a bare press or bare
pointer motion, neither of which a touch gesture produces. Coordinates are
clamped to the screen and a single `scrollWheel` call reports a bounded number of
detents, so no caller can put a malformed report or an unbounded burst on the
wire.

**Important**: Callbacks must not call back into Terminal methods (causes deadlock). Defer work to avoid reentrancy.

## Local libvterm modifications

`src/main/cpp/libvterm/` is vendored, and is **not** pristine upstream. Each
divergence is kept as a patch in `src/main/cpp/libvterm-patches/` as well as
being applied in tree, so that it survives a libvterm bump — CMake compiles the
vendored sources directly, so a bump that overwrites them drops the change
silently, and the symptom is a behavioural regression rather than a build
failure. After bumping, re-apply each patch and re-run the tests it names.

| Patch | File | Why |
| --- | --- | --- |
| `0001-reset-full-mouse-state.patch` | `src/state.c` | `vterm_state_reset()` cleared `mouse_flags` but left the report encoding and any held button stale, and switched reporting off without a `VTERM_PROP_MOUSE` callback — so an embedder mirroring that property kept routing gestures into a vterm that drops them. Not yet submitted upstream. |
27 changes: 27 additions & 0 deletions lib/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ package org.connectbot.terminal {
method public boolean isShiftActive();
}

public enum MouseButton {
enum_constant public static final org.connectbot.terminal.MouseButton LEFT;
enum_constant public static final org.connectbot.terminal.MouseButton MIDDLE;
enum_constant public static final org.connectbot.terminal.MouseButton RIGHT;
}

public enum MouseTracking {
method @InaccessibleFromKotlin public boolean isEnabled();
property public boolean isEnabled;
enum_constant public static final org.connectbot.terminal.MouseTracking CLICK;
enum_constant public static final org.connectbot.terminal.MouseTracking DRAG;
enum_constant public static final org.connectbot.terminal.MouseTracking MOVE;
enum_constant public static final org.connectbot.terminal.MouseTracking NONE;
}

public enum ProgressState {
enum_constant public static final org.connectbot.terminal.ProgressState DEFAULT;
enum_constant public static final org.connectbot.terminal.ProgressState ERROR;
Expand Down Expand Up @@ -106,15 +121,19 @@ package org.connectbot.terminal {
method @InaccessibleFromKotlin public boolean getBoldAsBright();
method @InaccessibleFromKotlin public org.connectbot.terminal.TerminalDimensions getDimensions();
method public String? getLastCommandOutput();
method @InaccessibleFromKotlin public org.connectbot.terminal.MouseTracking getMouseTracking();
method public java.util.List<org.connectbot.terminal.TerminalUrl> getUrls(optional org.connectbot.terminal.UrlScanScope scope);
method public void mouseClick(org.connectbot.terminal.MouseButton button, int row, int col, optional int modifiers);
method public void resize(int newRows, int newCols);
method public void scrollWheel(org.connectbot.terminal.WheelDirection direction, int row, int col, optional int steps, optional int modifiers);
method public int setAnsiPalette(int[] ansiColors);
method public int setDefaultColors(int foreground, int background);
method public void writeInput(byte[] data, optional int offset, optional int length);
method public void writeInput(java.nio.ByteBuffer buffer, int length);
property public abstract boolean autoDetectUrls;
property public abstract boolean boldAsBright;
property public abstract org.connectbot.terminal.TerminalDimensions dimensions;
property public abstract org.connectbot.terminal.MouseTracking mouseTracking;
}

public final class TerminalEmulatorFactory {
Expand Down Expand Up @@ -259,4 +278,12 @@ package org.connectbot.terminal {
field public static final int UP = 5; // 0x5
}

public enum WheelDirection {
enum_constant public static final org.connectbot.terminal.WheelDirection DOWN;
enum_constant public static final org.connectbot.terminal.WheelDirection LEFT;
enum_constant public static final org.connectbot.terminal.WheelDirection RIGHT;
enum_constant public static final org.connectbot.terminal.WheelDirection UP;
}

}

206 changes: 188 additions & 18 deletions lib/src/main/cpp/Terminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -395,19 +395,22 @@ int Terminal::setDefaultColors(uint32_t fgColor, uint32_t bgColor) {
}

// Keyboard input handlers
static VTermModifier toVTermModifier(int modifiers) {
VTermModifier mod = VTERM_MOD_NONE;
if (modifiers & 1) mod = (VTermModifier)(mod | VTERM_MOD_SHIFT);
if (modifiers & 2) mod = (VTermModifier)(mod | VTERM_MOD_ALT);
if (modifiers & 4) mod = (VTermModifier)(mod | VTERM_MOD_CTRL);
return mod;
}

bool Terminal::dispatchKey(int modifiers, int key) {
std::scoped_lock lock(mLock);

if (!mVt) {
return false;
}

VTermModifier mod = VTERM_MOD_NONE;
if (modifiers & 1) mod = (VTermModifier)(mod | VTERM_MOD_SHIFT);
if (modifiers & 2) mod = (VTermModifier)(mod | VTERM_MOD_ALT);
if (modifiers & 4) mod = (VTermModifier)(mod | VTERM_MOD_CTRL);

vterm_keyboard_key(mVt, (VTermKey)key, mod);
vterm_keyboard_key(mVt, (VTermKey)key, toVTermModifier(modifiers));
return true;
}

Expand All @@ -418,12 +421,86 @@ bool Terminal::dispatchCharacter(int modifiers, int codepoint) {
return false;
}

VTermModifier mod = VTERM_MOD_NONE;
if (modifiers & 1) mod = (VTermModifier)(mod | VTERM_MOD_SHIFT);
if (modifiers & 2) mod = (VTermModifier)(mod | VTERM_MOD_ALT);
if (modifiers & 4) mod = (VTermModifier)(mod | VTERM_MOD_CTRL);
vterm_keyboard_unichar(mVt, codepoint, toVTermModifier(modifiers));
return true;
}

// Mouse input handlers
VTermModifier Terminal::positionMouseLocked(int row, int col, int modifiers) {
VTermModifier mod = toVTermModifier(modifiers);

// libvterm only emits a report here when the application asked for drag or
// motion tracking; otherwise this just records the position that a
// subsequent button report will carry.
vterm_mouse_move(mVt,
std::clamp(row, 0, mRows > 0 ? mRows - 1 : 0),
std::clamp(col, 0, mCols > 0 ? mCols - 1 : 0),
mod);
return mod;
}

bool Terminal::mouseMove(int row, int col, int modifiers) {
std::scoped_lock lock(mLock);

vterm_keyboard_unichar(mVt, codepoint, mod);
if (!mVt) {
return false;
}

positionMouseLocked(row, col, modifiers);
return true;
}

bool Terminal::mouseButton(int row, int col, int button, bool pressed, int modifiers) {
std::scoped_lock lock(mLock);

if (!mVt) {
return false;
}

// Position and button are set under one lock: libvterm carries the position
// recorded by the move into the button report, so a concurrent move for a
// different gesture must not be able to land between the two.
vterm_mouse_button(mVt, button, pressed, positionMouseLocked(row, col, modifiers));
return true;
}

bool Terminal::mouseClick(int row, int col, int button, int modifiers) {
std::scoped_lock lock(mLock);

if (!mVt) {
return false;
}

// Press and release under one lock. An application tracks button state from
// these reports, so a press whose release is lost - dropped by a caller, or
// separated from it by a reset that clears the button state in between -
// leaves the application believing the button is still down. Emitting the
// pair as one operation means a click cannot be left half-delivered, and
// coalescing sends the pair in one trip rather than two.
CoalescedOutput out(this);
VTermModifier mod = positionMouseLocked(row, col, modifiers);
vterm_mouse_button(mVt, button, true, mod);
vterm_mouse_button(mVt, button, false, mod);
return true;
}

bool Terminal::scrollWheel(int row, int col, int button, int steps, int modifiers) {
std::scoped_lock lock(mLock);

if (!mVt || steps < 1) {
return false;
}

// One report per detent leaves libvterm, so the whole burst is collected
// and delivered in a single upcall rather than one per detent.
CoalescedOutput out(this);
VTermModifier mod = positionMouseLocked(row, col, modifiers);
int bounded = std::min(steps, MAX_WHEEL_STEPS_PER_CALL);
for (int i = 0; i < bounded; i++) {
// Wheel buttons report a press with no matching release; libvterm emits
// one report per call.
vterm_mouse_button(mVt, button, true, mod);
}
return true;
}

Expand Down Expand Up @@ -592,9 +669,24 @@ int Terminal::termSbClear(void* user) {

void Terminal::termOutput(const char* s, size_t len, void* user) {
auto* term = static_cast<Terminal*>(user);
if (term->mOutputSink) {
term->mOutputSink->append(s, len);
return;
}
term->invokeKeyboardOutput(s, len);
}

Terminal::CoalescedOutput::CoalescedOutput(Terminal* term) : mTerm(term) {
mTerm->mOutputSink = &mBuffer;
}

Terminal::CoalescedOutput::~CoalescedOutput() {
mTerm->mOutputSink = nullptr;
if (!mBuffer.empty()) {
mTerm->invokeKeyboardOutput(mBuffer.data(), mBuffer.size());
}
}

// OSC sequence fallback handler
// Handles fragmented OSC sequences by accumulating data across callbacks
int Terminal::termOscFallback(int command, VTermStringFragment frag, void* user) {
Expand Down Expand Up @@ -732,6 +824,22 @@ void Terminal::invokeMoveCursor(int row, int col, int oldRow, int oldCol, bool v
JNI_CHECK_EXCEPTION(env);
}

Terminal::PropCode Terminal::toPropCode(VTermProp prop) {
switch (prop) {
case VTERM_PROP_CURSORVISIBLE: return PropCode::CursorVisible;
case VTERM_PROP_CURSORBLINK: return PropCode::CursorBlink;
case VTERM_PROP_ALTSCREEN: return PropCode::AltScreen;
case VTERM_PROP_TITLE: return PropCode::Title;
case VTERM_PROP_ICONNAME: return PropCode::IconName;
case VTERM_PROP_REVERSE: return PropCode::Reverse;
case VTERM_PROP_CURSORSHAPE: return PropCode::CursorShape;
case VTERM_PROP_MOUSE: return PropCode::Mouse;
case VTERM_PROP_FOCUSREPORT: return PropCode::FocusReport;
case VTERM_N_PROPS: break;
}
return PropCode::Unknown;
}

void Terminal::invokeSetTermProp(VTermProp prop, VTermValue* val) {
if (!mSetTermPropMethod) {
return;
Expand All @@ -753,14 +861,44 @@ void Terminal::invokeSetTermProp(VTermProp prop, VTermValue* val) {
propValue = ScopedLocalRef<jobject>(env, env->NewObject(mTerminalPropertyIntClass, mTerminalPropertyIntConstructor, val->number));
break;

case VTERM_VALUETYPE_STRING:
if (val->string.str) {
char* utf8_str = mutf8_to_utf8(val->string.str, val->string.len, nullptr);
ScopedLocalRef<jstring> str(env, env->NewStringUTF(utf8_str));
propValue = ScopedLocalRef<jobject>(env, env->NewObject(mTerminalPropertyStringClass, mTerminalPropertyStringConstructor, str.get()));
free(utf8_str);
case VTERM_VALUETYPE_STRING: {
// libvterm hands a string property over in fragments, one per input
// buffer, so a title that straddles a PTY read arrives in pieces and
// the sequence ends with a fragment that is often empty. Forwarding
// each fragment on its own would let the last one win: a title split
// across two reads would arrive truncated to its tail, and a
// terminator arriving on its own would clear the title outright.
//
// So accumulate here and deliver once, the same shape as
// termOscFallback() and termSelectionSet(). Java then only ever sees
// whole values.
// Keyed by property rather than a single buffer: OSC 0 sets the icon
// name and the title from the same fragment, so two values are in
// flight at once and one buffer would interleave them.
std::string& buffer = mStringPropData[prop];

if (val->string.initial) {
buffer.clear();
}
if (val->string.str && val->string.len > 0) {
// Bounded because the payload is remote input and the sequence
// that ends it may never arrive. Excess is dropped rather than
// the value abandoned: an over-long title is still worth showing
// truncated, and a real one is a line at most.
size_t room = MAX_STRING_PROP_BYTES - std::min(buffer.size(), MAX_STRING_PROP_BYTES);
buffer.append(val->string.str, std::min(static_cast<size_t>(val->string.len), room));
}
if (!val->string.final) {
break;
}

char* utf8_str = mutf8_to_utf8(buffer.data(), buffer.size(), nullptr);
ScopedLocalRef<jstring> str(env, env->NewStringUTF(utf8_str));
propValue = ScopedLocalRef<jobject>(env, env->NewObject(mTerminalPropertyStringClass, mTerminalPropertyStringConstructor, str.get()));
free(utf8_str);
buffer.clear();
break;
}

case VTERM_VALUETYPE_COLOR: {
uint8_t r, g, b;
Expand All @@ -774,7 +912,8 @@ void Terminal::invokeSetTermProp(VTermProp prop, VTermValue* val) {
}

if (propValue.get()) {
env->CallIntMethod(mCallbacks, mSetTermPropMethod, prop, propValue.get());
env->CallIntMethod(mCallbacks, mSetTermPropMethod,
static_cast<jint>(toPropCode(prop)), propValue.get());
JNI_CHECK_EXCEPTION(env);
}
}
Expand Down Expand Up @@ -1224,6 +1363,37 @@ Java_org_connectbot_terminal_TerminalNative_nativeDispatchCharacter(JNIEnv* /* e
return term->dispatchCharacter(modifiers, character);
}

JNIEXPORT jboolean JNICALL
Java_org_connectbot_terminal_TerminalNative_nativeMouseMove(JNIEnv* /* env */, jobject /* thiz */,
jlong ptr, jint row, jint col, jint modifiers) {
auto* term = reinterpret_cast<Terminal*>(ptr);
return term->mouseMove(row, col, modifiers);
}

JNIEXPORT jboolean JNICALL
Java_org_connectbot_terminal_TerminalNative_nativeMouseButton(JNIEnv* /* env */, jobject /* thiz */,
jlong ptr, jint row, jint col, jint button,
jboolean pressed, jint modifiers) {
auto* term = reinterpret_cast<Terminal*>(ptr);
return term->mouseButton(row, col, button, pressed, modifiers);
}

JNIEXPORT jboolean JNICALL
Java_org_connectbot_terminal_TerminalNative_nativeMouseClick(JNIEnv* /* env */, jobject /* thiz */,
jlong ptr, jint row, jint col, jint button,
jint modifiers) {
auto* term = reinterpret_cast<Terminal*>(ptr);
return term->mouseClick(row, col, button, modifiers);
}

JNIEXPORT jboolean JNICALL
Java_org_connectbot_terminal_TerminalNative_nativeScrollWheel(JNIEnv* /* env */, jobject /* thiz */,
jlong ptr, jint row, jint col, jint button,
jint steps, jint modifiers) {
auto* term = reinterpret_cast<Terminal*>(ptr);
return term->scrollWheel(row, col, button, steps, modifiers);
}

JNIEXPORT jint JNICALL
Java_org_connectbot_terminal_TerminalNative_nativeGetCellRun(JNIEnv* env, jobject /* thiz */,
jlong ptr, jint row, jint col, jobject runObject) {
Expand Down
Loading