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
59 changes: 10 additions & 49 deletions terminal-emulator/build.gradle
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
apply plugin: 'com.android.library'
apply plugin: 'maven-publish'

android {
namespace "com.termux.emulator"
namespace = "com.termux.terminal"

compileSdkVersion project.properties.compileSdkVersion.toInteger()
ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion

dependencies {
implementation "androidx.annotation:annotation:1.9.0"
}

defaultConfig {
minSdkVersion project.properties.minSdkVersion.toInteger()
targetSdkVersion project.properties.targetSdkVersion.toInteger()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

externalNativeBuild {
ndkBuild {
cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections"
cFlags "-std=c11"
}
}

ndk {
abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
}
}

buildTypes {
Expand All @@ -29,57 +29,18 @@ android {
}
}

externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

testOptions {
unitTests.returnDefaultValues = true
}

publishing {
multipleVariants {
withSourcesJar()
withJavadocJar()
allVariants()
externalNativeBuild {
ndkBuild {
path "src/main/jni/Android.mk"
}
}
}

tasks.withType(Test) {
testLogging {
events "started", "passed", "skipped", "failed"
}
}

dependencies {
implementation "androidx.annotation:annotation:1.9.0"
testImplementation "junit:junit:4.13.2"
}

task sourceJar(type: Jar) {
from android.sourceSets.main.java.srcDirs
archiveClassifier = "sources"
}

afterEvaluate {
publishing {
publications {
// Creates a Maven publication called "release".
release(MavenPublication) {
from components.default
groupId = 'com.termux'
artifactId = 'terminal-emulator'
version = '0.118.0'
artifact(sourceJar)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,42 +65,63 @@ public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolea
if (selY2 >= mScreenRows) selY2 = mScreenRows - 1;

for (int row = selY1; row <= selY2; row++) {
int x1 = (row == selY1) ? selX1 : 0;
int x2;
if (row == selY2) {
x2 = selX2 + 1;
if (x2 > columns) x2 = columns;
} else {
x2 = columns;
}
TerminalRow lineObject = mLines[externalToInternalRow(row)];
int x1Index = lineObject.findStartOfColumn(x1);
int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed();
if (x2Index == x1Index) {
// Selected the start of a wide character.
x2Index = lineObject.findStartOfColumn(x2 + 1);
if (lineObject == null) continue;

int[] l2v = lineObject.mLogicalToVisual;

int vStart, vEnd;
if (row == selY1 && row == selY2) {
int v1 = (l2v != null) ? l2v[Math.min(columns - 1, Math.max(0, selX1))] : selX1;
int v2 = (l2v != null) ? l2v[Math.min(columns - 1, Math.max(0, selX2))] : selX2;
vStart = Math.min(v1, v2);
vEnd = Math.max(v1, v2);
} else if (row == selY1) {
vStart = (l2v != null) ? l2v[Math.min(columns - 1, Math.max(0, selX1))] : selX1;
vEnd = columns - 1;
} else if (row == selY2) {
vStart = 0;
vEnd = (l2v != null) ? l2v[Math.min(columns - 1, Math.max(0, selX2))] : selX2;
} else {
vStart = 0;
vEnd = columns - 1;
}

char[] line = lineObject.mText;
int lastPrintingCharIndex = -1;
int i;
int lineObjectSpaceUsed = lineObject.getSpaceUsed();

StringBuilder rowBuilder = new StringBuilder();
for (int col = 0; col < columns; ) {
int vCol = (l2v != null) ? l2v[col] : col;
int x1Index = lineObject.findStartOfColumn(col);
int nextCol = col + 1;
int x2Index = (nextCol < columns) ? lineObject.findStartOfColumn(nextCol) : lineObjectSpaceUsed;

if (vCol >= vStart && vCol <= vEnd) {
if (x2Index > x1Index) {
rowBuilder.append(line, x1Index, x2Index - x1Index);
}
}
col = nextCol;
}

boolean rowLineWrap = getLineWrap(row);
if (rowLineWrap && x2 == columns) {
// If the line was wrapped, we shouldn't lose trailing space:
lastPrintingCharIndex = x2Index - 1;
} else {
for (i = x1Index; i < x2Index; ++i) {
char c = line[i];
if (c != ' ') lastPrintingCharIndex = i;
String rowText = rowBuilder.toString();
if (!rowLineWrap) {
int len = rowText.length();
while (len > 0 && rowText.charAt(len - 1) == ' ') {
len--;
}
rowText = rowText.substring(0, len);
}

int len = lastPrintingCharIndex - x1Index + 1;
if (lastPrintingCharIndex != -1 && len > 0)
builder.append(line, x1Index, len);
builder.append(rowText);

boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1;
boolean lineFillsWidth = rowBuilder.length() > 0 && rowBuilder.charAt(rowBuilder.length() - 1) != ' ';
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
&& row < selY2 && row < mScreenRows - 1) builder.append('\n');
&& row < selY2 && row < mScreenRows - 1) {
builder.append('\n');
}
}
return builder.toString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ public final class TerminalRow {
final long[] mStyle;
/** If this row might contain chars with width != 1, used for deactivating fast path */
boolean mHasNonOneWidthOrSurrogateChars;
/** Cached visual layout object. Type is Object to decouple terminal emulator from view module. */
public Object mCachedBidiLayout;
public int[] mLogicalToVisual;
public int[] mVisualToLogical;

/** Construct a blank row (containing only whitespace, ' ') with a specified style. */
public TerminalRow(int columns, long style) {
Expand Down Expand Up @@ -146,13 +150,19 @@ public void clear(long style) {
Arrays.fill(mStyle, style);
mSpaceUsed = (short) mColumns;
mHasNonOneWidthOrSurrogateChars = false;
mCachedBidiLayout = null; // Invalidate cache
mLogicalToVisual = null;
mVisualToLogical = null;
}

// https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
public void setChar(int columnToSet, int codePoint, long style) {
if (columnToSet < 0 || columnToSet >= mStyle.length)
throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style);

mCachedBidiLayout = null; // Invalidate cache
mLogicalToVisual = null;
mVisualToLogical = null;
mStyle[columnToSet] = style;

final int newCodePointDisplayWidth = WcWidth.width(codePoint);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.termux.terminal;

/**
* Standalone tests for the character-buffer encoding logic extracted from TerminalRenderer.
* Can be run directly via "java TerminalRendererBufferTest.java" or compiled with javac.
*/
public class TerminalRendererBufferTest {

private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15;

public static void main(String[] args) {
System.out.println("Running TerminalRendererBufferTest...");
testCapacity_bmpNoCombining();
testCapacity_supplementaryBase();
testCapacity_maxCombiningBmp();
testCapacity_maxCombiningSupplementary();
testEncode_fourCombiningDiacritics();
testEncode_arabicFullyVocalized();
testEncode_maxCombining_noCrash();
testEncode_supplementaryBaseAndCombiner();
testAsciiGate_withCombiner_isNotAsciiPath();
testAsciiGate_plainAscii_usesFastPath();
testAsciiGate_boundary_del();
System.out.println("ALL TESTS PASSED SUCCESSFULLY! ✅");
}

private static void assertEquals(int expected, int actual) {
if (expected != actual) throw new AssertionError("Expected " + expected + " but got " + actual);
}

private static void assertEquals(char expected, char actual) {
if (expected != actual) throw new AssertionError("Expected " + (int)expected + " but got " + (int)actual);
}

private static void assertTrue(String msg, boolean condition) {
if (!condition) throw new AssertionError(msg);
}

private static void assertFalse(String msg, boolean condition) {
if (condition) throw new AssertionError(msg);
}

// ---------------------------------------------------------------------------
// Helpers that mirror the logic in TerminalRenderer exactly
// ---------------------------------------------------------------------------

private static int cellCapacity(int baseCodePoint, int[] combiningCodePoints) {
int cap = Character.charCount(baseCodePoint);
if (combiningCodePoints != null) {
for (int cp : combiningCodePoints)
cap += Character.charCount(cp);
}
return cap;
}

private static char[] encodeCell(int baseCodePoint, int[] combiningCodePoints) {
int cap = cellCapacity(baseCodePoint, combiningCodePoints);
char[] buf = new char[cap];
int used = Character.toChars(baseCodePoint, buf, 0);
if (combiningCodePoints != null) {
for (int cp : combiningCodePoints)
used += Character.toChars(cp, buf, used);
}
assertEquals(cap, used);
return buf;
}

// ---------------------------------------------------------------------------
// 1. Combining-character buffer capacity
// ---------------------------------------------------------------------------

public static void testCapacity_bmpNoCombining() {
assertEquals(1, cellCapacity('A', null));
}

public static void testCapacity_supplementaryBase() {
int smp = 0x1F600; // 😀 GRINNING FACE
assertEquals(2, cellCapacity(smp, null));
}

public static void testCapacity_maxCombiningBmp() {
int[] combiners = new int[MAX_COMBINING_CHARACTERS_PER_COLUMN];
for (int i = 0; i < combiners.length; i++)
combiners[i] = 0x0301; // COMBINING ACUTE ACCENT (U+0301)

int expected = 1 + MAX_COMBINING_CHARACTERS_PER_COLUMN;
assertEquals(expected, cellCapacity('a', combiners));
}

public static void testCapacity_maxCombiningSupplementary() {
int base = 0x11000; // arbitrary supplementary base
int combiner = 0x1D167; // MUSICAL SYMBOL COMBINING TREMOLO-1 (supplementary combiner)

int[] combiners = new int[MAX_COMBINING_CHARACTERS_PER_COLUMN];
for (int i = 0; i < combiners.length; i++)
combiners[i] = combiner;

int expected = 2 + MAX_COMBINING_CHARACTERS_PER_COLUMN * 2;
assertEquals(expected, cellCapacity(base, combiners));
}

// ---------------------------------------------------------------------------
// 2. Encoding correctness — no truncation, no ArrayIndexOutOfBoundsException
// ---------------------------------------------------------------------------

public static void testEncode_fourCombiningDiacritics() {
int[] combiners = { 0x0300, 0x0301, 0x0302, 0x0303 };
char[] buf = encodeCell('e', combiners);
assertEquals(5, buf.length);
assertEquals('e', buf[0]);
assertEquals((char) 0x0300, buf[1]);
assertEquals((char) 0x0303, buf[4]);
}

public static void testEncode_arabicFullyVocalized() {
// Arabic base letter + shadda + fatha + kasra + tanwin
int[] combiners = { 0x0651, 0x064E, 0x0650, 0x064B };
char[] buf = encodeCell(0x0628 /* ب */, combiners);
assertEquals(5, buf.length);
assertEquals((char) 0x0628, buf[0]);
}

public static void testEncode_maxCombining_noCrash() {
int[] combiners = new int[MAX_COMBINING_CHARACTERS_PER_COLUMN];
for (int i = 0; i < combiners.length; i++)
combiners[i] = 0x0301;

char[] buf = encodeCell('a', combiners);
assertEquals(1 + MAX_COMBINING_CHARACTERS_PER_COLUMN, buf.length);
assertEquals('a', buf[0]);
}

public static void testEncode_supplementaryBaseAndCombiner() {
int base = 0x11000;
int combiner = 0x1D167;
char[] buf = encodeCell(base, new int[]{ combiner });
assertEquals(4, buf.length);
assertEquals(base, Character.codePointAt(buf, 0));
assertEquals(combiner, Character.codePointAt(buf, 2));
}

// ---------------------------------------------------------------------------
// 3. ASCII fast-path — lookup table values are consistent with direct encoding
// ---------------------------------------------------------------------------

public static void testAsciiGate_withCombiner_isNotAsciiPath() {
int base = 'A'; // ASCII
int[] combiners = { 0x0301 }; // non-null combining array
boolean wouldUseFastPath = (base < 127) && (combiners == null);
assertFalse("ASCII fast-path must NOT activate when combiners are present", wouldUseFastPath);
}

public static void testAsciiGate_plainAscii_usesFastPath() {
int base = 'Z';
boolean wouldUseFastPath = (base < 127) && (true);
assertTrue("ASCII fast-path must activate for plain ASCII with no combiners", wouldUseFastPath);
}

public static void testAsciiGate_boundary_del() {
int base = 127;
boolean wouldUseFastPath = (base < 127);
assertFalse("Codepoint 127 must fall through to measureText path", wouldUseFastPath);
}
}
Loading