From 2a09126f2c626b481b1b9e6256eff7733f4831da Mon Sep 17 00:00:00 2001 From: HanzoDev1375 Date: Thu, 13 Aug 2026 20:06:15 +0330 Subject: [PATCH 1/2] fix:fix show char farsi --- terminal-emulator/build.gradle | 2 +- .../com/termux/terminal/TerminalBuffer.java | 75 ++-- .../java/com/termux/terminal/TerminalRow.java | 10 + .../terminal/TerminalRendererBufferTest.java | 164 ++++++++ terminal-view/build.gradle | 3 +- .../main/java/com/termux/view/BidiLayout.java | 258 +++++++++++++ .../com/termux/view/TerminalRenderer.java | 350 +++++++++++++----- .../java/com/termux/view/TerminalView.java | 20 +- .../TextSelectionCursorController.java | 44 ++- .../TextSelectionHandleView.java | 2 +- 10 files changed, 786 insertions(+), 142 deletions(-) create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/TerminalRendererBufferTest.java create mode 100644 terminal-view/src/main/java/com/termux/view/BidiLayout.java diff --git a/terminal-emulator/build.gradle b/terminal-emulator/build.gradle index ff1ebae3c7..8088cb42e0 100644 --- a/terminal-emulator/build.gradle +++ b/terminal-emulator/build.gradle @@ -82,4 +82,4 @@ afterEvaluate { } } } -} +} \ No newline at end of file diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java index 21d6518785..cbcc0ac5b0 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -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(); } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java index d68dc32623..8dcaa367d5 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -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) { @@ -146,6 +150,9 @@ 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 @@ -153,6 +160,9 @@ 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); diff --git a/terminal-emulator/src/test/java/com/termux/terminal/TerminalRendererBufferTest.java b/terminal-emulator/src/test/java/com/termux/terminal/TerminalRendererBufferTest.java new file mode 100644 index 0000000000..58a65b3a3d --- /dev/null +++ b/terminal-emulator/src/test/java/com/termux/terminal/TerminalRendererBufferTest.java @@ -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); + } +} diff --git a/terminal-view/build.gradle b/terminal-view/build.gradle index 75dff01fa3..706670ef06 100644 --- a/terminal-view/build.gradle +++ b/terminal-view/build.gradle @@ -59,5 +59,4 @@ afterEvaluate { } } } -} - +} \ No newline at end of file diff --git a/terminal-view/src/main/java/com/termux/view/BidiLayout.java b/terminal-view/src/main/java/com/termux/view/BidiLayout.java new file mode 100644 index 0000000000..c388afff4b --- /dev/null +++ b/terminal-view/src/main/java/com/termux/view/BidiLayout.java @@ -0,0 +1,258 @@ +package com.termux.view; + +import com.termux.terminal.TerminalRow; +import com.termux.terminal.WcWidth; +import java.text.Bidi; + +public final class BidiLayout { + + public static class LogicalCell { + public int codePoint; + public int displayWidth; + public long style; + public boolean insideCursor; + public boolean insideSelection; + public int originalColumn; + public boolean isRtl; + public int[] combiningChars = null; + public int combiningCount = 0; + + public void addCombining(int cp) { + if (combiningChars == null) { + combiningChars = new int[4]; + } else if (combiningCount == combiningChars.length) { + int[] newArr = new int[combiningChars.length * 2]; + System.arraycopy(combiningChars, 0, newArr, 0, combiningChars.length); + combiningChars = newArr; + } + combiningChars[combiningCount++] = cp; + } + } + + public final LogicalCell[] visualCells; + public final int[] logicalToVisual; + public final int[] visualToLogical; + + public BidiLayout(LogicalCell[] visualCells, int[] logicalToVisual, int[] visualToLogical) { + this.visualCells = visualCells; + this.logicalToVisual = logicalToVisual; + this.visualToLogical = visualToLogical; + } + + /** + * Builds or retrieves a cached visual layout for a terminal row. + * Maps logical terminal grid columns to visual characters and manages RTL/LTR reordering. + * Uses a fast-path caching strategy to update cursor and selection states in-place, + * avoiding Bidi calculations and array allocations on frame refreshes. + */ + public static BidiLayout build(TerminalRow rowObject, int columns, int cursorCol, boolean cursorVisible, + int selectionY1, int selectionY2, int selectionX1, int selectionX2, int row, + boolean isForRendering) { + // 1. Fast-path: Check cached layout + if (rowObject.mCachedBidiLayout instanceof BidiLayout) { + BidiLayout cached = (BidiLayout) rowObject.mCachedBidiLayout; + if (cached.visualCells.length == columns) { + if (isForRendering) { + // Update cursor and selection flags in-place (extremely fast, zero-allocation) + int vSelStart = -1; + int vSelEnd = -1; + boolean hasSel = false; + if (row >= selectionY1 && row <= selectionY2) { + hasSel = true; + if (selectionY1 == selectionY2) { + int v1 = cached.logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX1))]; + int v2 = cached.logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX2))]; + vSelStart = Math.min(v1, v2); + vSelEnd = Math.max(v1, v2); + } else if (row == selectionY1) { + vSelStart = cached.logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX1))]; + vSelEnd = columns - 1; + } else if (row == selectionY2) { + vSelStart = 0; + vSelEnd = cached.logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX2))]; + } else { + vSelStart = 0; + vSelEnd = columns - 1; + } + } + + for (int i = 0; i < columns; i++) { + LogicalCell cell = cached.visualCells[i]; + int lCol = cached.visualToLogical[i]; + cell.insideCursor = (lCol == cursorCol && cursorVisible); + cell.insideSelection = hasSel && (i >= vSelStart && i <= vSelEnd); + } + } + return cached; + } + } + + // 2. Cache miss: Build a new layout + LogicalCell[] logicalCells = new LogicalCell[columns]; + for (int i = 0; i < columns; i++) { + logicalCells[i] = new LogicalCell(); + logicalCells[i].codePoint = ' '; + logicalCells[i].displayWidth = 1; + logicalCells[i].style = rowObject.getStyle(i); + logicalCells[i].insideCursor = (i == cursorCol && cursorVisible); + logicalCells[i].insideSelection = false; + logicalCells[i].originalColumn = i; + logicalCells[i].isRtl = false; + } + + char[] line = rowObject.mText; + int charsUsedInLine = rowObject.getSpaceUsed(); + int currentCharIndex = 0; + + for (int column = 0; column < columns; ) { + if (currentCharIndex >= charsUsedInLine) { + break; + } + char c = line[currentCharIndex]; + boolean isHigh = Character.isHighSurrogate(c); + int charsCount = isHigh ? 2 : 1; + + int codePoint = isHigh ? Character.toCodePoint(c, line[currentCharIndex + 1]) : c; + int w = WcWidth.width(codePoint); + if (w <= 0) w = 1; + + if (column < columns) { + LogicalCell cell = logicalCells[column]; + cell.codePoint = codePoint; + cell.displayWidth = w; + + if (w == 2 && column + 1 < columns) { + logicalCells[column + 1].codePoint = 0; + logicalCells[column + 1].displayWidth = 0; + logicalCells[column + 1].insideCursor = (column + 1 == cursorCol && cursorVisible); + } + } + + currentCharIndex += charsCount; + + while (currentCharIndex < charsUsedInLine) { + char nextChar = line[currentCharIndex]; + boolean nextIsHigh = Character.isHighSurrogate(nextChar); + int nextCp = nextIsHigh ? Character.toCodePoint(nextChar, line[currentCharIndex + 1]) : nextChar; + if (WcWidth.width(nextCp) <= 0) { + if (column < columns) { + logicalCells[column].addCombining(nextCp); + } + currentCharIndex += nextIsHigh ? 2 : 1; + } else { + break; + } + } + column += w; + } + + // 3. Trailing Space Protection: Scan for the last active column + int activeLength = 0; + for (int i = columns - 1; i >= 0; i--) { + if (logicalCells[i].codePoint != ' ' && logicalCells[i].codePoint != 0) { + activeLength = i + 1; + break; + } + } + + int[] logicalToVisual = new int[columns]; + int[] visualToLogical = new int[columns]; + LogicalCell[] visualCells = new LogicalCell[columns]; + + if (activeLength <= 0) { + // Whole line is blank, identity mapping + for (int i = 0; i < columns; i++) { + logicalToVisual[i] = i; + visualToLogical[i] = i; + visualCells[i] = logicalCells[i]; + } + BidiLayout newLayout = new BidiLayout(visualCells, logicalToVisual, visualToLogical); + rowObject.mLogicalToVisual = logicalToVisual; + rowObject.mVisualToLogical = visualToLogical; + rowObject.mCachedBidiLayout = newLayout; + return newLayout; + } + + // Convert to Char array for Bidi (active portion only) + char[] bidiChars = new char[activeLength]; + for (int i = 0; i < activeLength; i++) { + int cp = logicalCells[i].codePoint; + if (cp == 0) { + bidiChars[i] = ' '; + } else if (Character.isSupplementaryCodePoint(cp)) { + bidiChars[i] = ' '; // standard LTR placeholder + } else { + bidiChars[i] = (char) cp; + } + } + + Bidi bidi = new Bidi(bidiChars, 0, null, 0, activeLength, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT); + + if (bidi.isLeftToRight()) { + for (int i = 0; i < columns; i++) { + logicalToVisual[i] = i; + visualToLogical[i] = i; + visualCells[i] = logicalCells[i]; + } + } else { + byte[] levels = new byte[activeLength]; + Integer[] activeVisualToLogical = new Integer[activeLength]; + for (int i = 0; i < activeLength; i++) { + levels[i] = (byte) bidi.getLevelAt(i); + activeVisualToLogical[i] = i; + logicalCells[i].isRtl = (levels[i] % 2 != 0); + } + Bidi.reorderVisually(levels, 0, activeVisualToLogical, 0, activeLength); + + // Map active reordered portion + for (int i = 0; i < activeLength; i++) { + visualToLogical[i] = activeVisualToLogical[i]; + logicalToVisual[visualToLogical[i]] = i; + visualCells[i] = logicalCells[visualToLogical[i]]; + } + + // Map trailing portion as identity (forces spaces to remain LTR on the right) + for (int i = activeLength; i < columns; i++) { + logicalToVisual[i] = i; + visualToLogical[i] = i; + visualCells[i] = logicalCells[i]; + } + } + + // Populate visual selection + int vSelStart = -1; + int vSelEnd = -1; + boolean hasSel = false; + if (row >= selectionY1 && row <= selectionY2) { + hasSel = true; + if (selectionY1 == selectionY2) { + int v1 = logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX1))]; + int v2 = logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX2))]; + vSelStart = Math.min(v1, v2); + vSelEnd = Math.max(v1, v2); + } else if (row == selectionY1) { + vSelStart = logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX1))]; + vSelEnd = columns - 1; + } else if (row == selectionY2) { + vSelStart = 0; + vSelEnd = logicalToVisual[Math.min(columns - 1, Math.max(0, selectionX2))]; + } else { + vSelStart = 0; + vSelEnd = columns - 1; + } + } + + for (int i = 0; i < columns; i++) { + LogicalCell cell = visualCells[i]; + int lCol = visualToLogical[i]; + cell.insideCursor = (lCol == cursorCol && cursorVisible); + cell.insideSelection = hasSel && (i >= vSelStart && i <= vSelEnd); + } + + BidiLayout newLayout = new BidiLayout(visualCells, logicalToVisual, visualToLogical); + rowObject.mLogicalToVisual = logicalToVisual; + rowObject.mVisualToLogical = visualToLogical; + rowObject.mCachedBidiLayout = newLayout; + return newLayout; + } +} diff --git a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java index a4bef7d37c..652a4d9d0a 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -12,9 +12,18 @@ import com.termux.terminal.WcWidth; /** - * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. - *

- * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + * Renders a {@link TerminalEmulator} into a {@link Canvas}. + * + *

Caches font metrics; must be recreated whenever the typeface or font size changes. + * + *

Rendering proceeds row by row. Within each row, cells with identical style, directionality, + * and width-fit are batched into a single run and drawn in one {@link Canvas#drawTextRun} + * call. RTL runs are reordered into logical order before drawing so that the platform shaping + * engine can apply correct cursive joining. + * + *

Width measurement uses a pre-computed lookup table ({@link #asciiMeasures}) for plain ASCII + * cells to avoid calling {@link Paint#measureText} on every cell every frame. The expensive + * measurement path is taken only for non-ASCII, RTL, or combining-character cells. */ public final class TerminalRenderer { @@ -22,15 +31,23 @@ public final class TerminalRenderer { final Typeface mTypeface; private final Paint mTextPaint = new Paint(); - /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + /** Width of a single monospaced character, measured as {@code measureText("X")}. */ final float mFontWidth; - /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + + /** {@link Paint#getFontSpacing()} rounded up to the nearest pixel. */ final int mFontLineSpacing; - /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + + /** {@link Paint#ascent()} rounded up to the nearest pixel. */ private final int mFontAscent; - /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + + /** {@link #mFontLineSpacing} + {@link #mFontAscent}. */ final int mFontLineSpacingAndAscent; + /** + * Pre-computed advance widths for the first 127 ASCII codepoints. + * Indexed directly by codepoint value; avoids per-cell {@link Paint#measureText} overhead + * for the common case of standard terminal output. + */ private final float[] asciiMeasures = new float[127]; public TerminalRenderer(int textSize, Typeface typeface) { @@ -53,7 +70,7 @@ public TerminalRenderer(int textSize, Typeface typeface) { } } - /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + /** Renders the terminal into {@code canvas}, starting at {@code topRow} with an optional selection range. */ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, int selectionY1, int selectionY2, int selectionX1, int selectionX2) { final boolean reverseVideo = mEmulator.isReverseVideo(); @@ -73,92 +90,213 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, for (int row = topRow; row < endRow; row++) { heightOffset += mFontLineSpacing; - final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; - int selx1 = -1, selx2 = -1; - if (row >= selectionY1 && row <= selectionY2) { - if (row == selectionY1) selx1 = selectionX1; - selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; - } - TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); - final char[] line = lineObject.mText; - final int charsUsedInLine = lineObject.getSpaceUsed(); + BidiLayout layout = BidiLayout.build(lineObject, columns, cursorCol, + row == cursorRow && cursorVisible, selectionY1, selectionY2, selectionX1, selectionX2, row, true); + BidiLayout.LogicalCell[] visualCells = layout.visualCells; long lastRunStyle = 0; boolean lastRunInsideCursor = false; boolean lastRunInsideSelection = false; + boolean lastRunIsRtl = false; int lastRunStartColumn = -1; - int lastRunStartIndex = 0; boolean lastRunFontWidthMismatch = false; - int currentCharIndex = 0; - float measuredWidthForRun = 0.f; - - for (int column = 0; column < columns; ) { - final char charAtIndex = line[currentCharIndex]; - final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); - final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; - final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; - final int codePointWcWidth = WcWidth.width(codePoint); - final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); - final boolean insideSelection = column >= selx1 && column <= selx2; - final long style = lineObject.getStyle(column); - - // Check if the measured text width for this code point is not the same as that expected by wcwidth(). - // This could happen for some fonts which are not truly monospace, or for more exotic characters such as - // smileys which android font renders as wide. - // If this is detected, we draw this code point scaled to match what wcwidth() expects. - final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, - currentCharIndex, charsForCodePoint); - final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; - - if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { - if (column == 0) { - // Skip first column as there is nothing to draw, just record the current style. - } else { - final int columnWidthSinceLastRun = column - lastRunStartColumn; - final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; - int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; - boolean invertCursorTextColor = false; - if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { - invertCursorTextColor = true; - } - drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, - lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, - cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + + for (int vCol = 0; vCol < columns; ) { + BidiLayout.LogicalCell cell = visualCells[vCol]; + if (cell.displayWidth == 0 && cell.codePoint == 0) { + vCol++; + continue; + } + + final boolean insideCursor = cell.insideCursor; + final boolean insideSelection = cell.insideSelection; + final long style = cell.style; + final int codePoint = cell.codePoint; + final int codePointWcWidth = cell.displayWidth; + final boolean isRtl = cell.isRtl; + + // Measure the advance width of this cell. ASCII cells without combining characters + // use the pre-computed lookup table; all other cells call measureText. + final float measuredCodePointWidth; + if (codePoint == 0) { + measuredCodePointWidth = 0f; + } else if (codePoint < asciiMeasures.length && cell.combiningChars == null) { + measuredCodePointWidth = asciiMeasures[codePoint]; + } else { + int cap = Character.charCount(codePoint) + + (cell.combiningChars != null ? cell.combiningCount * 2 : 0); + char[] buf = new char[cap]; + int len = Character.toChars(codePoint, buf, 0); + if (cell.combiningChars != null) { + for (int i = 0; i < cell.combiningCount; i++) + len += Character.toChars(cell.combiningChars[i], buf, len); + } + measuredCodePointWidth = mTextPaint.measureText(buf, 0, len); + } + + final boolean fontWidthMismatch = !isRtl && codePointWcWidth > 0 + && Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle + || insideCursor != lastRunInsideCursor + || insideSelection != lastRunInsideSelection + || isRtl != lastRunIsRtl + || fontWidthMismatch + || lastRunFontWidthMismatch) { + if (vCol != 0) { + flushRun(canvas, mEmulator, visualCells, palette, heightOffset, + lastRunStartColumn, vCol, lastRunStyle, + lastRunInsideCursor, lastRunInsideSelection, + lastRunIsRtl, lastRunFontWidthMismatch, + cursorShape, reverseVideo); } - measuredWidthForRun = 0.f; lastRunStyle = style; lastRunInsideCursor = insideCursor; lastRunInsideSelection = insideSelection; - lastRunStartColumn = column; - lastRunStartIndex = currentCharIndex; + lastRunIsRtl = isRtl; + lastRunStartColumn = vCol; lastRunFontWidthMismatch = fontWidthMismatch; } - measuredWidthForRun += measuredCodePointWidth; - column += codePointWcWidth; - currentCharIndex += charsForCodePoint; - while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { - // Eat combining chars so that they are treated as part of the last non-combining code point, - // instead of e.g. being considered inside the cursor in the next run. - currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + + vCol += codePointWcWidth; + } + + if (columns > lastRunStartColumn) { + flushRun(canvas, mEmulator, visualCells, palette, heightOffset, + lastRunStartColumn, columns, lastRunStyle, + lastRunInsideCursor, lastRunInsideSelection, + lastRunIsRtl, lastRunFontWidthMismatch, + cursorShape, reverseVideo); + } + } + } + + /** + * Collects cells in the column range [{@code startCol}, {@code endCol}), builds a UTF-16 + * character buffer, measures the run width, and delegates to {@link #drawTextRun}. + * + *

RTL runs are insertion-sorted into logical (left-to-right) column order before encoding + * so that {@link Canvas#drawTextRun} receives characters in the order the shaping engine + * expects for correct bidirectional cursive rendering. + * + *

The character buffer is sized precisely by summing {@link Character#charCount} over every + * base codepoint and each of its combining characters, so supplementary-plane codepoints that + * require a surrogate pair are always accommodated without overflow. + */ + private void flushRun(Canvas canvas, + TerminalEmulator emulator, + BidiLayout.LogicalCell[] visualCells, + int[] palette, + float heightOffset, + int startCol, int endCol, + long style, + boolean insideCursor, boolean insideSelection, + boolean isRtl, boolean fontWidthMismatch, + int cursorShape, boolean reverseVideo) { + + final int runColumns = endCol - startCol; + final int cursorColor = insideCursor + ? emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + final boolean invertCursorTextColor = + insideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK; + + // Collect non-empty cells. + int count = 0; + BidiLayout.LogicalCell[] cells = new BidiLayout.LogicalCell[runColumns]; + for (int c = startCol; c < endCol; c++) { + BidiLayout.LogicalCell rc = visualCells[c]; + if (rc.displayWidth == 0 && rc.codePoint == 0) continue; + cells[count++] = rc; + } + + // Reorder RTL cells into logical column order for the shaping engine. + if (isRtl) { + for (int i = 1; i < count; i++) { + BidiLayout.LogicalCell key = cells[i]; + int j = i - 1; + while (j >= 0 && cells[j].originalColumn > key.originalColumn) { + cells[j + 1] = cells[j]; + j--; + } + cells[j + 1] = key; + } + } + + // Compute exact buffer capacity: each codepoint (base or combining) may need 2 chars. + int capacity = 0; + for (int i = 0; i < count; i++) { + BidiLayout.LogicalCell rc = cells[i]; + if (rc.codePoint == 0) { + capacity += 1; + } else { + capacity += Character.charCount(rc.codePoint); + if (rc.combiningChars != null) { + for (int k = 0; k < rc.combiningCount; k++) + capacity += Character.charCount(rc.combiningChars[k]); + } + } + } + + char[] runBuffer = new char[capacity]; + int used = 0; + for (int i = 0; i < count; i++) { + BidiLayout.LogicalCell rc = cells[i]; + if (rc.codePoint != 0) { + used += Character.toChars(rc.codePoint, runBuffer, used); + if (rc.combiningChars != null) { + for (int k = 0; k < rc.combiningCount; k++) + used += Character.toChars(rc.combiningChars[k], runBuffer, used); } + } else if (rc.displayWidth > 0) { + runBuffer[used++] = ' '; } + } - final int columnWidthSinceLastRun = columns - lastRunStartColumn; - final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; - int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; - boolean invertCursorTextColor = false; - if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { - invertCursorTextColor = true; + // Switch to the system default typeface for RTL runs to enable native cursive shaping. + final Typeface originalTypeface = mTextPaint.getTypeface(); + if (isRtl) mTextPaint.setTypeface(Typeface.DEFAULT); + + // Determine the run's rendered advance width. + final float measuredWidth; + if (isRtl) { + measuredWidth = mTextPaint.measureText(runBuffer, 0, used); + } else if (fontWidthMismatch) { + float total = 0f; + for (int i = 0; i < count; i++) { + BidiLayout.LogicalCell rc = cells[i]; + if (rc.codePoint != 0) { + int cap = Character.charCount(rc.codePoint) + + (rc.combiningChars != null ? rc.combiningCount * 2 : 0); + char[] t = new char[cap]; + int tl = Character.toChars(rc.codePoint, t, 0); + if (rc.combiningChars != null) { + for (int k = 0; k < rc.combiningCount; k++) + tl += Character.toChars(rc.combiningChars[k], t, tl); + } + total += mTextPaint.measureText(t, 0, tl); + } else { + total += mFontWidth; + } } - drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, - measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + measuredWidth = total; + } else { + measuredWidth = runColumns * mFontWidth; } + + drawTextRun(canvas, runBuffer, palette, heightOffset, + startCol, runColumns, 0, used, measuredWidth, + cursorColor, cursorShape, style, + reverseVideo || invertCursorTextColor || insideSelection, isRtl); + + if (isRtl) mTextPaint.setTypeface(originalTypeface); } - private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, - int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, - long textStyle, boolean reverseVideo) { + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, + int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, + float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo, boolean isRtl) { int foreColor = TextStyle.decodeForeColor(textStyle); final int effect = TextStyle.decodeEffect(textStyle); int backColor = TextStyle.decodeBackColor(textStyle); @@ -169,17 +307,16 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; if ((foreColor & 0xff000000) != 0xff000000) { - // Let bold have bright colors if applicable (one of the first 8): + // Bold text in the first 8 palette entries maps to the bright variant. if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; foreColor = palette[foreColor]; } - if ((backColor & 0xff000000) != 0xff000000) { backColor = palette[backColor]; } - // Reverse video here if _one and only one_ of the reverse flags are set: - final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + // Reverse video is active when exactly one of the global and per-cell flags is set. + final boolean reverseVideoHere = reverseVideo ^ (effect & TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0; if (reverseVideoHere) { int tmp = foreColor; foreColor = backColor; @@ -189,6 +326,8 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int float left = startColumn * mFontWidth; float right = left + runWidthColumns * mFontWidth; + // Scale the canvas horizontally if the font's advance differs from the cell grid width, + // keeping the text centred within its allocated columns. mes = mes / mFontWidth; boolean savedMatrix = false; if (Math.abs(mes - runWidthColumns) > 0.01) { @@ -200,7 +339,6 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int } if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { - // Only draw non-default background. mTextPaint.setColor(backColor); canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); } @@ -215,15 +353,12 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { if (dim) { - int red = (0xFF & (foreColor >> 16)); - int green = (0xFF & (foreColor >> 8)); - int blue = (0xFF & foreColor); - // Dim color handling used by libvte which in turn took it from xterm - // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): - red = red * 2 / 3; - green = green * 2 / 3; - blue = blue * 2 / 3; - foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + // Dim colour algorithm from libvte / xterm: + // https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267 + int red = (0xFF & (foreColor >> 16)) * 2 / 3; + int green = (0xFF & (foreColor >> 8)) * 2 / 3; + int blue = (0xFF & foreColor) * 2 / 3; + foreColor = 0xFF000000 | (red << 16) | (green << 8) | blue; } mTextPaint.setFakeBoldText(bold); @@ -232,8 +367,9 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int mTextPaint.setStrikeThruText(strikeThrough); mTextPaint.setColor(foreColor); - // The text alignment is the default Paint.Align.LEFT. - canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); + canvas.drawTextRun(text, startCharIndex, runWidthChars, + startCharIndex, runWidthChars, + left, y - mFontLineSpacingAndAscent, isRtl, mTextPaint); } if (savedMatrix) canvas.restore(); @@ -246,4 +382,38 @@ public float getFontWidth() { public int getFontLineSpacing() { return mFontLineSpacing; } + + public int translateVisualToLogicalColumn(TerminalEmulator mEmulator, int visualCol, int row) { + if (visualCol < 0) return 0; + if (visualCol >= mEmulator.mColumns) return mEmulator.mColumns - 1; + + TerminalBuffer screen = mEmulator.getScreen(); + if (row < -screen.getActiveTranscriptRows() || row >= mEmulator.mRows) return visualCol; + + int internalRow = screen.externalToInternalRow(row); + if (internalRow < 0 || internalRow >= screen.getActiveRows()) return visualCol; + + TerminalRow rowObject = screen.allocateFullLineIfNecessary(internalRow); + if (rowObject == null) return visualCol; + + BidiLayout layout = BidiLayout.build(rowObject, mEmulator.mColumns, -1, false, -1, -1, -1, -1, -1, false); + return layout.visualToLogical[visualCol]; + } + + public int translateLogicalToVisualColumn(TerminalEmulator mEmulator, int logicalCol, int row) { + if (logicalCol < 0) return 0; + if (logicalCol >= mEmulator.mColumns) return mEmulator.mColumns - 1; + + TerminalBuffer screen = mEmulator.getScreen(); + if (row < -screen.getActiveTranscriptRows() || row >= mEmulator.mRows) return logicalCol; + + int internalRow = screen.externalToInternalRow(row); + if (internalRow < 0 || internalRow >= screen.getActiveRows()) return logicalCol; + + TerminalRow rowObject = screen.allocateFullLineIfNecessary(internalRow); + if (rowObject == null) return logicalCol; + + BidiLayout layout = BidiLayout.build(rowObject, mEmulator.mColumns, -1, false, -1, -1, -1, -1, -1, false); + return layout.logicalToVisual[logicalCol]; + } } diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index 0b3f515682..c4f7b8d486 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -546,10 +546,12 @@ public boolean isOpaque() { public int[] getColumnAndRow(MotionEvent event, boolean relativeToScroll) { int column = (int) (event.getX() / mRenderer.mFontWidth); int row = (int) ((event.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + int logicalRow = row; if (relativeToScroll) { - row += mTopRow; + logicalRow += mTopRow; } - return new int[] { column, row }; + column = mRenderer.translateVisualToLogicalColumn(mEmulator, column, logicalRow); + return new int[] { column, logicalRow }; } /** Send a single mouse event code to the terminal. */ @@ -1032,7 +1034,12 @@ private CharSequence getText() { } public int getCursorX(float x) { - return (int) (x / mRenderer.mFontWidth); + return getCursorX(x, mEmulator.getCursorRow()); + } + + public int getCursorX(float x, int row) { + int visualCol = (int) (x / mRenderer.mFontWidth); + return mRenderer.translateVisualToLogicalColumn(mEmulator, visualCol, row); } public int getCursorY(float y) { @@ -1040,10 +1047,15 @@ public int getCursorY(float y) { } public int getPointX(int cx) { + return getPointX(cx, mEmulator.getCursorRow()); + } + + public int getPointX(int cx, int cy) { if (cx > mEmulator.mColumns) { cx = mEmulator.mColumns; } - return Math.round(cx * mRenderer.mFontWidth); + int visualCol = mRenderer.translateLogicalToVisualColumn(mEmulator, cx, cy); + return Math.round(visualCol * mRenderer.mFontWidth); } public int getPointY(int cy) { diff --git a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java index c2cd7c6c0a..0db6475aa9 100644 --- a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java +++ b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java @@ -192,8 +192,10 @@ public void onDestroyActionMode(ActionMode mode) { @Override public void onGetContentRect(ActionMode mode, View view, Rect outRect) { - int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth()); - int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth()); + int visualCol1 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX1, mSelY1); + int visualCol2 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX2, mSelY2); + int x1 = Math.round(visualCol1 * terminalView.mRenderer.getFontWidth()); + int x2 = Math.round(visualCol2 * terminalView.mRenderer.getFontWidth()); int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); @@ -219,25 +221,28 @@ public void updatePosition(TextSelectionHandleView handle, int x, int y) { TerminalBuffer screen = terminalView.mEmulator.getScreen(); final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows; if (handle == mStartHandle) { - mSelX1 = terminalView.getCursorX(x); mSelY1 = terminalView.getCursorY(y); - if (mSelX1 < 0) { - mSelX1 = 0; - } - if (mSelY1 < -scrollRows) { mSelY1 = -scrollRows; - } else if (mSelY1 > terminalView.mEmulator.mRows - 1) { mSelY1 = terminalView.mEmulator.mRows - 1; + } + mSelX1 = terminalView.getCursorX(x, mSelY1); + if (mSelX1 < 0) { + mSelX1 = 0; } if (mSelY1 > mSelY2) { mSelY1 = mSelY2; } - if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { - mSelX1 = mSelX2; + if (mSelY1 == mSelY2) { + int vSelX1 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX1, mSelY1); + int vSelX2 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX2, mSelY2); + if (vSelX1 > vSelX2) { + vSelX1 = vSelX2; + mSelX1 = terminalView.mRenderer.translateVisualToLogicalColumn(terminalView.mEmulator, vSelX1, mSelY1); + } } if (!terminalView.mEmulator.isAlternateBufferActive()) { @@ -261,23 +266,28 @@ public void updatePosition(TextSelectionHandleView handle, int x, int y) { mSelX1 = getValidCurX(screen, mSelY1, mSelX1); } else { - mSelX2 = terminalView.getCursorX(x); mSelY2 = terminalView.getCursorY(y); - if (mSelX2 < 0) { - mSelX2 = 0; - } - if (mSelY2 < -scrollRows) { mSelY2 = -scrollRows; } else if (mSelY2 > terminalView.mEmulator.mRows - 1) { mSelY2 = terminalView.mEmulator.mRows - 1; } + mSelX2 = terminalView.getCursorX(x, mSelY2); + if (mSelX2 < 0) { + mSelX2 = 0; + } + if (mSelY1 > mSelY2) { mSelY2 = mSelY1; } - if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { - mSelX2 = mSelX1; + if (mSelY1 == mSelY2) { + int vSelX1 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX1, mSelY1); + int vSelX2 = terminalView.mRenderer.translateLogicalToVisualColumn(terminalView.mEmulator, mSelX2, mSelY2); + if (vSelX1 > vSelX2) { + vSelX2 = vSelX1; + mSelX2 = terminalView.mRenderer.translateVisualToLogicalColumn(terminalView.mEmulator, vSelX2, mSelY2); + } } if (!terminalView.mEmulator.isAlternateBufferActive()) { diff --git a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java index b3caca6720..b2a6f95a8c 100644 --- a/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java +++ b/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java @@ -154,7 +154,7 @@ public void removeFromParent() { } public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) { - int x = terminalView.getPointX(cx); + int x = terminalView.getPointX(cx, cy); int y = terminalView.getPointY(cy + 1); moveTo(x, y, forceOrientationCheck); } From 646c7f21c3357b4e68833782a2f7c4453e6f4947 Mon Sep 17 00:00:00 2001 From: HanzoDev1375 Date: Thu, 13 Aug 2026 20:42:57 +0330 Subject: [PATCH 2/2] Now match the Persian letters with the symbol location. --- terminal-emulator/build.gradle | 59 ++----- terminal-view/build.gradle | 43 +----- .../main/java/com/termux/view/BidiLayout.java | 41 ++++- .../com/termux/view/TerminalRenderer.java | 144 ++++++++++++++++-- 4 files changed, 186 insertions(+), 101 deletions(-) diff --git a/terminal-emulator/build.gradle b/terminal-emulator/build.gradle index 8088cb42e0..d4bea029a1 100644 --- a/terminal-emulator/build.gradle +++ b/terminal-emulator/build.gradle @@ -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 { @@ -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) - } - } - } -} \ No newline at end of file diff --git a/terminal-view/build.gradle b/terminal-view/build.gradle index 706670ef06..47e24d2e54 100644 --- a/terminal-view/build.gradle +++ b/terminal-view/build.gradle @@ -1,19 +1,20 @@ apply plugin: 'com.android.library' -apply plugin: 'maven-publish' android { - namespace "com.termux.view" + namespace = "com.termux.view" + compileSdkVersion project.properties.compileSdkVersion.toInteger() + ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion dependencies { - implementation "androidx.annotation:annotation:1.9.0" - api project(":terminal-emulator") + implementation "androidx.appcompat:appcompat:1.6.1" + implementation project(":terminal-emulator") } defaultConfig { minSdkVersion project.properties.minSdkVersion.toInteger() targetSdkVersion project.properties.targetSdkVersion.toInteger() - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + consumerProguardFiles "proguard-rules.pro" } buildTypes { @@ -27,36 +28,4 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - - publishing { - multipleVariants { - withSourcesJar() - withJavadocJar() - allVariants() - } - } -} - -dependencies { - 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-view' - version = '0.118.0' - artifact(sourceJar) - } - } - } -} \ No newline at end of file diff --git a/terminal-view/src/main/java/com/termux/view/BidiLayout.java b/terminal-view/src/main/java/com/termux/view/BidiLayout.java index c388afff4b..791dac5151 100644 --- a/terminal-view/src/main/java/com/termux/view/BidiLayout.java +++ b/terminal-view/src/main/java/com/termux/view/BidiLayout.java @@ -76,10 +76,10 @@ public static BidiLayout build(TerminalRow rowObject, int columns, int cursorCol } } + int caretCol = cursorVisible ? caretVisualColumn(cached.visualToLogical, cached.visualCells, cursorCol) : -1; for (int i = 0; i < columns; i++) { LogicalCell cell = cached.visualCells[i]; - int lCol = cached.visualToLogical[i]; - cell.insideCursor = (lCol == cursorCol && cursorVisible); + cell.insideCursor = (i == caretCol); cell.insideSelection = hasSel && (i >= vSelStart && i <= vSelEnd); } } @@ -242,10 +242,10 @@ public static BidiLayout build(TerminalRow rowObject, int columns, int cursorCol } } + int caretCol = cursorVisible ? caretVisualColumn(visualToLogical, visualCells, cursorCol) : -1; for (int i = 0; i < columns; i++) { LogicalCell cell = visualCells[i]; - int lCol = visualToLogical[i]; - cell.insideCursor = (lCol == cursorCol && cursorVisible); + cell.insideCursor = (i == caretCol); cell.insideSelection = hasSel && (i >= vSelStart && i <= vSelEnd); } @@ -255,4 +255,37 @@ public static BidiLayout build(TerminalRow rowObject, int columns, int cursorCol rowObject.mCachedBidiLayout = newLayout; return newLayout; } + + /** + * Computes the visual column where the caret should be drawn for a logical cursor position. + * + *

The caret sits at the visual boundary after (LTR) or before (RTL) the character that + * precedes the logical cursor column. This keeps the caret on the correct side of + * right-to-left text: after typing a Persian word the caret appears at the left edge of the + * word instead of trailing off to the right, and it stays adjacent to the characters being + * typed. + */ + private static int caretVisualColumn(int[] visualToLogical, LogicalCell[] visualCells, int cursorCol) { + if (cursorCol <= 0) return 0; + int prevLogical = cursorCol - 1; + for (int i = 0; i < visualToLogical.length; i++) { + if (visualToLogical[i] == prevLogical) { + LogicalCell prev = visualCells[i]; + int caret; + if (prev.isRtl) { + caret = i; + } else { + // Caret after an LTR character. A double-width character occupies two cells; + // a cursor on its second half stays on that half cell instead of jumping past + // the character. + caret = i + Math.max(1, prev.displayWidth); + if (prev.displayWidth == 2 && cursorCol == prevLogical + 1) caret = i + 1; + } + return Math.max(0, Math.min(visualToLogical.length - 1, caret)); + } + } + // The cell preceding the cursor is outside the active text (e.g. a blank line), so the + // caret keeps its identity mapping. + return Math.max(0, Math.min(visualToLogical.length - 1, cursorCol)); + } } diff --git a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java index 652a4d9d0a..7d3ac898dd 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -50,6 +50,14 @@ public final class TerminalRenderer { */ private final float[] asciiMeasures = new float[127]; + /** + * Translucent highlight used to mark selected cells inside RTL runs. RTL text cannot be + * rendered with the inverse-video trick used for LTR runs because splitting a shaped run to + * invert part of it breaks cursive letter joining, so selection is drawn as a background + * overlay instead. + */ + private static final int RTL_SELECTION_HIGHLIGHT_COLOR = 0x6680B0FF; + public TerminalRenderer(int textSize, Typeface typeface) { mTextSize = textSize; mTypeface = typeface; @@ -138,12 +146,20 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final boolean fontWidthMismatch = !isRtl && codePointWcWidth > 0 && Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; - if (style != lastRunStyle - || insideCursor != lastRunInsideCursor - || insideSelection != lastRunInsideSelection + // Split runs on style, directionality or font width changes. RTL runs are never + // split at the cursor or selection boundaries: splitting a shaped RTL word (e.g. + // Persian) breaks cursive letter joining, so for RTL runs the cursor and selection + // are drawn as overlays on top of the finished run instead. + final boolean rtlRun = isRtl && lastRunIsRtl; + final boolean styleOrDirectionChanged = style != lastRunStyle || isRtl != lastRunIsRtl || fontWidthMismatch - || lastRunFontWidthMismatch) { + || lastRunFontWidthMismatch; + final boolean cellStateChanged = !rtlRun + && (insideCursor != lastRunInsideCursor + || insideSelection != lastRunInsideSelection); + + if (styleOrDirectionChanged || cellStateChanged) { if (vCol != 0) { flushRun(canvas, mEmulator, visualCells, palette, heightOffset, lastRunStartColumn, vCol, lastRunStyle, @@ -196,10 +212,29 @@ private void flushRun(Canvas canvas, int cursorShape, boolean reverseVideo) { final int runColumns = endCol - startCol; - final int cursorColor = insideCursor - ? emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; - final boolean invertCursorTextColor = - insideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK; + + // For RTL runs the cursor cell is located on the visual row (it can be anywhere inside the + // run since RTL runs are not split at the cursor), so it is resolved here and drawn as an + // overlay on top of the finished run in drawRtlCursorAndSelectionOverlays(). + final int cursorColor; + final boolean invertCursorTextColor; + if (isRtl) { + int cursorVisualCol = -1; + for (int c = startCol; c < endCol; c++) { + if (visualCells[c].insideCursor) { + cursorVisualCol = c; + break; + } + } + cursorColor = cursorVisualCol >= 0 + ? emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + invertCursorTextColor = false; + } else { + cursorColor = insideCursor + ? emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + invertCursorTextColor = + insideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK; + } // Collect non-empty cells. int count = 0; @@ -286,10 +321,14 @@ private void flushRun(Canvas canvas, drawTextRun(canvas, runBuffer, palette, heightOffset, startCol, runColumns, 0, used, measuredWidth, - cursorColor, cursorShape, style, - reverseVideo || invertCursorTextColor || insideSelection, isRtl); + isRtl ? 0 : cursorColor, cursorShape, style, + reverseVideo || (isRtl ? false : invertCursorTextColor || insideSelection), isRtl); - if (isRtl) mTextPaint.setTypeface(originalTypeface); + if (isRtl) { + mTextPaint.setTypeface(originalTypeface); + drawRtlCursorAndSelectionOverlays(canvas, visualCells, startCol, endCol, palette, + heightOffset, cursorShape, cursorColor, reverseVideo); + } } private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, @@ -375,6 +414,89 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, if (savedMatrix) canvas.restore(); } + /** + * Draws the selection highlight and caret as overlays for an RTL run. The run itself was + * already drawn as a single shaped unit (preserving cursive joining); the overlays are drawn + * on top in unscaled, absolute screen coordinates so they always line up with the run's cell + * grid. + */ + private void drawRtlCursorAndSelectionOverlays(Canvas canvas, + BidiLayout.LogicalCell[] visualCells, + int startCol, int endCol, + int[] palette, float heightOffset, + int cursorShape, int cursorColor, + boolean reverseVideo) { + + // Selection: a translucent highlight rect over the selected cells. + int selStart = -1; + for (int v = startCol; v <= endCol; v++) { + boolean selected = v < endCol && visualCells[v].insideSelection; + if (selected && selStart < 0) { + selStart = v; + } else if (!selected && selStart >= 0) { + drawCellRect(canvas, selStart, v, heightOffset, RTL_SELECTION_HIGHLIGHT_COLOR); + selStart = -1; + } + } + + // Caret: drawn at the exact cell that holds the cursor. + for (int v = startCol; v < endCol; v++) { + BidiLayout.LogicalCell rc = visualCells[v]; + if (!rc.insideCursor) continue; + + float left = v * mFontWidth; + int widthCells = rc.displayWidth > 0 ? rc.displayWidth : 1; + float right = left + widthCells * mFontWidth; + + float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; + if (cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; + else if (cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + + mTextPaint.setColor(cursorColor); + canvas.drawRect(left, heightOffset - cursorHeight, right, heightOffset, mTextPaint); + + if (cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK && rc.codePoint != 0) { + // Redraw the caret cell's character in the cell background colour so the block + // caret looks like an inverted character, as with LTR text. + int backColor = TextStyle.decodeBackColor(rc.style); + if ((backColor & 0xff000000) != 0xff000000) backColor = palette[backColor]; + if (reverseVideo) backColor = cursorColor; + + char[] buf = buildCharBuffer(rc); + final Typeface originalTypeface = mTextPaint.getTypeface(); + mTextPaint.setTypeface(Typeface.DEFAULT); + mTextPaint.setFakeBoldText(false); + mTextPaint.setUnderlineText(false); + mTextPaint.setTextSkewX(0.f); + mTextPaint.setStrikeThruText(false); + mTextPaint.setColor(backColor); + canvas.drawText(buf, 0, buf.length, left, heightOffset - mFontLineSpacingAndAscent, mTextPaint); + mTextPaint.setTypeface(originalTypeface); + } + break; + } + } + + private void drawCellRect(Canvas canvas, int startCol, int endCol, float rowBottom, int color) { + float left = startCol * mFontWidth; + float right = endCol * mFontWidth; + float top = rowBottom - mFontLineSpacingAndAscent + mFontAscent; + mTextPaint.setColor(color); + canvas.drawRect(left, top, right, rowBottom, mTextPaint); + } + + private static char[] buildCharBuffer(BidiLayout.LogicalCell rc) { + int capacity = Character.charCount(rc.codePoint); + if (rc.combiningChars != null) capacity += rc.combiningCount * 2; + char[] buf = new char[capacity]; + int len = Character.toChars(rc.codePoint, buf, 0); + if (rc.combiningChars != null) { + for (int k = 0; k < rc.combiningCount; k++) + len += Character.toChars(rc.combiningChars[k], buf, len); + } + return buf; + } + public float getFontWidth() { return mFontWidth; }