diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e1b78f..d9cd710 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
~~什么我居然开始写更新日志了~~
+## 0.2.1
+
+- 改进: 全屏虚拟按钮更多调整选项
+- 改进: 全屏悬浮球更多调整选项
+- 改进: 终端使用 Termux 套件
+- 新增: `--no-mouse-hover`, `--kill-adb-on-close`
+- 移除: 旧版本配置迁移逻辑
+- 依赖: AGP `9.1.1` -> `9.2.0`, Gradle `9.4.0` -> `9.4.1`
+
## 0.2.0
- 新增: 流式终端界面(支持在应用内直接操作终端)
@@ -22,13 +31,13 @@
## 0.1.7
-- 重构: 低延迟音频处理从 JNI 侧迁移到 Kotlin/应用侧,降低 JNI 路径负担
+- 新增: Kotlin 侧低延迟音频路径
- 改进: 音频相关实现结构与设置项联动
## 0.1.6
- 修复: 输入法双重 padding 导致的布局问题
-- 重构: UI 刷新(TopAppBar 与 Dock 视觉重做,交互与页面样式统一)
+- 重构: UI 焕新(TopAppBar 与 Dock 视觉重做,交互与页面样式统一)
## 0.1.5
diff --git a/README.md b/README.md
index ab31c11..058592b 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
-
-
+
+
diff --git a/ScrcpyForAndroid.code-workspace b/ScrcpyForAndroid.code-workspace
index 71fd5df..a2952f4 100644
--- a/ScrcpyForAndroid.code-workspace
+++ b/ScrcpyForAndroid.code-workspace
@@ -37,6 +37,9 @@
},
{
"path": "../termux-app"
+ },
+ {
+ "path": "../termux-kotlin-app"
}
],
}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index de7aa3a..ca72871 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,8 +54,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 37
- versionCode = 16
- versionName = "0.2.0"
+ versionCode = 17
+ versionName = "0.2.1"
externalNativeBuild {
cmake {
diff --git a/app/src/main/java/com/termux/terminal/KeyHandler.kt b/app/src/main/java/com/termux/terminal/KeyHandler.kt
new file mode 100644
index 0000000..df865eb
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/KeyHandler.kt
@@ -0,0 +1,185 @@
+package com.termux.terminal
+
+import android.view.KeyEvent.*
+
+object KeyHandler {
+
+ const val KEYMOD_ALT = 0x80000000.toInt()
+ const val KEYMOD_CTRL = 0x40000000
+ const val KEYMOD_SHIFT = 0x20000000
+ const val KEYMOD_NUM_LOCK = 0x10000000
+
+ private val TERMCAP_TO_KEYCODE = HashMap().apply {
+ // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html
+ // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html
+ put("%i", KEYMOD_SHIFT or KEYCODE_DPAD_RIGHT)
+ put("#2", KEYMOD_SHIFT or KEYCODE_MOVE_HOME) // Shifted home
+ put("#4", KEYMOD_SHIFT or KEYCODE_DPAD_LEFT)
+ put("*7", KEYMOD_SHIFT or KEYCODE_MOVE_END) // Shifted end key
+
+ put("k1", KEYCODE_F1)
+ put("k2", KEYCODE_F2)
+ put("k3", KEYCODE_F3)
+ put("k4", KEYCODE_F4)
+ put("k5", KEYCODE_F5)
+ put("k6", KEYCODE_F6)
+ put("k7", KEYCODE_F7)
+ put("k8", KEYCODE_F8)
+ put("k9", KEYCODE_F9)
+ put("k;", KEYCODE_F10)
+ put("F1", KEYCODE_F11)
+ put("F2", KEYCODE_F12)
+ put("F3", KEYMOD_SHIFT or KEYCODE_F1)
+ put("F4", KEYMOD_SHIFT or KEYCODE_F2)
+ put("F5", KEYMOD_SHIFT or KEYCODE_F3)
+ put("F6", KEYMOD_SHIFT or KEYCODE_F4)
+ put("F7", KEYMOD_SHIFT or KEYCODE_F5)
+ put("F8", KEYMOD_SHIFT or KEYCODE_F6)
+ put("F9", KEYMOD_SHIFT or KEYCODE_F7)
+ put("FA", KEYMOD_SHIFT or KEYCODE_F8)
+ put("FB", KEYMOD_SHIFT or KEYCODE_F9)
+ put("FC", KEYMOD_SHIFT or KEYCODE_F10)
+ put("FD", KEYMOD_SHIFT or KEYCODE_F11)
+ put("FE", KEYMOD_SHIFT or KEYCODE_F12)
+
+ put("kb", KEYCODE_DEL) // backspace key
+
+ put("kd", KEYCODE_DPAD_DOWN) // terminfo=kcud1, down-arrow key
+ put("kh", KEYCODE_MOVE_HOME)
+ put("kl", KEYCODE_DPAD_LEFT)
+ put("kr", KEYCODE_DPAD_RIGHT)
+
+ // K1=Upper left of keypad:
+ // t_K1 keypad home key
+ // t_K3 keypad page-up key
+ // t_K4 keypad end key
+ // t_K5 keypad page-down key
+ put("K1", KEYCODE_MOVE_HOME)
+ put("K3", KEYCODE_PAGE_UP)
+ put("K4", KEYCODE_MOVE_END)
+ put("K5", KEYCODE_PAGE_DOWN)
+
+ put("ku", KEYCODE_DPAD_UP)
+
+ put("kB", KEYMOD_SHIFT or KEYCODE_TAB) // termcap=kB, terminfo=kcbt: Back-tab
+ put("kD", KEYCODE_FORWARD_DEL) // terminfo=kdch1, delete-character key
+ put("kDN", KEYMOD_SHIFT or KEYCODE_DPAD_DOWN) // non-standard shifted arrow down
+ put("kF", KEYMOD_SHIFT or KEYCODE_DPAD_DOWN) // terminfo=kind, scroll-forward key
+ put("kI", KEYCODE_INSERT)
+ put("kN", KEYCODE_PAGE_UP)
+ put("kP", KEYCODE_PAGE_DOWN)
+ put("kR", KEYMOD_SHIFT or KEYCODE_DPAD_UP) // terminfo=kri, scroll-backward key
+ put("kUP", KEYMOD_SHIFT or KEYCODE_DPAD_UP) // non-standard shifted up
+
+ put("@7", KEYCODE_MOVE_END)
+ put("@8", KEYCODE_NUMPAD_ENTER)
+ }
+
+ @JvmStatic
+ fun getCodeFromTermcap(termcap: String, cursorKeysApplication: Boolean, keypadApplication: Boolean): String? {
+ val keyCodeAndMod = TERMCAP_TO_KEYCODE[termcap] ?: return null
+ var keyCode = keyCodeAndMod
+ var keyMod = 0
+ if ((keyCode and KEYMOD_SHIFT) != 0) {
+ keyMod = keyMod or KEYMOD_SHIFT
+ keyCode = keyCode and KEYMOD_SHIFT.inv()
+ }
+ if ((keyCode and KEYMOD_CTRL) != 0) {
+ keyMod = keyMod or KEYMOD_CTRL
+ keyCode = keyCode and KEYMOD_CTRL.inv()
+ }
+ if ((keyCode and KEYMOD_ALT) != 0) {
+ keyMod = keyMod or KEYMOD_ALT
+ keyCode = keyCode and KEYMOD_ALT.inv()
+ }
+ if ((keyCode and KEYMOD_NUM_LOCK) != 0) {
+ keyMod = keyMod or KEYMOD_NUM_LOCK
+ keyCode = keyCode and KEYMOD_NUM_LOCK.inv()
+ }
+ return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication)
+ }
+
+ @JvmStatic
+ fun getCode(keyCode: Int, keyMode: Int, cursorApp: Boolean, keypadApplication: Boolean): String? {
+ val numLockOn = (keyMode and KEYMOD_NUM_LOCK) != 0
+ val keyMod = keyMode and KEYMOD_NUM_LOCK.inv()
+ return when (keyCode) {
+ KEYCODE_DPAD_CENTER -> "\r"
+
+ KEYCODE_DPAD_UP -> if (keyMod == 0) (if (cursorApp) "\u001bOA" else "\u001b[A") else transformForModifiers("\u001b[1", keyMod, 'A')
+ KEYCODE_DPAD_DOWN -> if (keyMod == 0) (if (cursorApp) "\u001bOB" else "\u001b[B") else transformForModifiers("\u001b[1", keyMod, 'B')
+ KEYCODE_DPAD_RIGHT -> if (keyMod == 0) (if (cursorApp) "\u001bOC" else "\u001b[C") else transformForModifiers("\u001b[1", keyMod, 'C')
+ KEYCODE_DPAD_LEFT -> if (keyMod == 0) (if (cursorApp) "\u001bOD" else "\u001b[D") else transformForModifiers("\u001b[1", keyMod, 'D')
+
+ KEYCODE_MOVE_HOME -> if (keyMod == 0) (if (cursorApp) "\u001bOH" else "\u001b[H") else transformForModifiers("\u001b[1", keyMod, 'H')
+ KEYCODE_MOVE_END -> if (keyMod == 0) (if (cursorApp) "\u001bOF" else "\u001b[F") else transformForModifiers("\u001b[1", keyMod, 'F')
+
+ KEYCODE_F1 -> if (keyMod == 0) "\u001bOP" else transformForModifiers("\u001b[1", keyMod, 'P')
+ KEYCODE_F2 -> if (keyMod == 0) "\u001bOQ" else transformForModifiers("\u001b[1", keyMod, 'Q')
+ KEYCODE_F3 -> if (keyMod == 0) "\u001bOR" else transformForModifiers("\u001b[1", keyMod, 'R')
+ KEYCODE_F4 -> if (keyMod == 0) "\u001bOS" else transformForModifiers("\u001b[1", keyMod, 'S')
+ KEYCODE_F5 -> transformForModifiers("\u001b[15", keyMod, '~')
+ KEYCODE_F6 -> transformForModifiers("\u001b[17", keyMod, '~')
+ KEYCODE_F7 -> transformForModifiers("\u001b[18", keyMod, '~')
+ KEYCODE_F8 -> transformForModifiers("\u001b[19", keyMod, '~')
+ KEYCODE_F9 -> transformForModifiers("\u001b[20", keyMod, '~')
+ KEYCODE_F10 -> transformForModifiers("\u001b[21", keyMod, '~')
+ KEYCODE_F11 -> transformForModifiers("\u001b[23", keyMod, '~')
+ KEYCODE_F12 -> transformForModifiers("\u001b[24", keyMod, '~')
+
+ KEYCODE_SYSRQ -> "\u001b[32~" // Sys Request / Print
+ KEYCODE_BREAK -> "\u001b[34~" // Pause/Break
+
+ KEYCODE_ESCAPE, KEYCODE_BACK -> "\u001b"
+
+ KEYCODE_INSERT -> transformForModifiers("\u001b[2", keyMod, '~')
+ KEYCODE_FORWARD_DEL -> transformForModifiers("\u001b[3", keyMod, '~')
+
+ KEYCODE_PAGE_UP -> transformForModifiers("\u001b[5", keyMod, '~')
+ KEYCODE_PAGE_DOWN -> transformForModifiers("\u001b[6", keyMod, '~')
+ KEYCODE_DEL -> {
+ val prefix = if ((keyMod and KEYMOD_ALT) == 0) "" else "\u001b"
+ prefix + if ((keyMod and KEYMOD_CTRL) == 0) "\u007F" else "\u0008"
+ }
+ KEYCODE_NUM_LOCK -> if (keypadApplication) "\u001bOP" else null
+ KEYCODE_SPACE -> if ((keyMod and KEYMOD_CTRL) == 0) null else "\u0000"
+ KEYCODE_TAB -> if ((keyMod and KEYMOD_SHIFT) == 0) "\t" else "\u001b[Z"
+ KEYCODE_ENTER -> if ((keyMod and KEYMOD_ALT) == 0) "\r" else "\u001b\r"
+
+ KEYCODE_NUMPAD_ENTER -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'M') else "\n"
+ KEYCODE_NUMPAD_MULTIPLY -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'j') else "*"
+ KEYCODE_NUMPAD_ADD -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'k') else "+"
+ KEYCODE_NUMPAD_COMMA -> ","
+ KEYCODE_NUMPAD_DOT -> if (numLockOn) (if (keypadApplication) "\u001bOn" else ".") else transformForModifiers("\u001b[3", keyMod, '~')
+ KEYCODE_NUMPAD_SUBTRACT -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'm') else "-"
+ KEYCODE_NUMPAD_DIVIDE -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'o') else "/"
+ KEYCODE_NUMPAD_0 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'p') else "0") else transformForModifiers("\u001b[2", keyMod, '~')
+ KEYCODE_NUMPAD_1 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'q') else "1") else (if (keyMod == 0) (if (cursorApp) "\u001bOF" else "\u001b[F") else transformForModifiers("\u001b[1", keyMod, 'F'))
+ KEYCODE_NUMPAD_2 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'r') else "2") else (if (keyMod == 0) (if (cursorApp) "\u001bOB" else "\u001b[B") else transformForModifiers("\u001b[1", keyMod, 'B'))
+ KEYCODE_NUMPAD_3 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 's') else "3") else "\u001b[6~"
+ KEYCODE_NUMPAD_4 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 't') else "4") else (if (keyMod == 0) (if (cursorApp) "\u001bOD" else "\u001b[D") else transformForModifiers("\u001b[1", keyMod, 'D'))
+ KEYCODE_NUMPAD_5 -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'u') else "5"
+ KEYCODE_NUMPAD_6 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'v') else "6") else (if (keyMod == 0) (if (cursorApp) "\u001bOC" else "\u001b[C") else transformForModifiers("\u001b[1", keyMod, 'C'))
+ KEYCODE_NUMPAD_7 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'w') else "7") else (if (keyMod == 0) (if (cursorApp) "\u001bOH" else "\u001b[H") else transformForModifiers("\u001b[1", keyMod, 'H'))
+ KEYCODE_NUMPAD_8 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'x') else "8") else (if (keyMod == 0) (if (cursorApp) "\u001bOA" else "\u001b[A") else transformForModifiers("\u001b[1", keyMod, 'A'))
+ KEYCODE_NUMPAD_9 -> if (numLockOn) (if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'y') else "9") else "\u001b[5~"
+ KEYCODE_NUMPAD_EQUALS -> if (keypadApplication) transformForModifiers("\u001bO", keyMod, 'X') else "="
+
+ else -> null
+ }
+ }
+
+ private fun transformForModifiers(start: String, keymod: Int, lastChar: Char): String {
+ val modifier = when (keymod) {
+ KEYMOD_SHIFT -> 2
+ KEYMOD_ALT -> 3
+ KEYMOD_SHIFT or KEYMOD_ALT -> 4
+ KEYMOD_CTRL -> 5
+ KEYMOD_SHIFT or KEYMOD_CTRL -> 6
+ KEYMOD_ALT or KEYMOD_CTRL -> 7
+ KEYMOD_SHIFT or KEYMOD_ALT or KEYMOD_CTRL -> 8
+ else -> return start + lastChar
+ }
+ return "$start;$modifier$lastChar"
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/Logger.kt b/app/src/main/java/com/termux/terminal/Logger.kt
new file mode 100644
index 0000000..2b3c3d8
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/Logger.kt
@@ -0,0 +1,86 @@
+package com.termux.terminal
+
+import android.util.Log
+import java.io.PrintWriter
+import java.io.StringWriter
+
+object Logger {
+
+ @JvmStatic
+ fun logError(client: TerminalSessionClient?, logTag: String, message: String) {
+ if (client != null) {
+ client.logError(logTag, message)
+ } else {
+ Log.e(logTag, message)
+ }
+ }
+
+ @JvmStatic
+ fun logWarn(client: TerminalSessionClient?, logTag: String, message: String) {
+ if (client != null) {
+ client.logWarn(logTag, message)
+ } else {
+ Log.w(logTag, message)
+ }
+ }
+
+ @JvmStatic
+ fun logInfo(client: TerminalSessionClient?, logTag: String, message: String) {
+ if (client != null) {
+ client.logInfo(logTag, message)
+ } else {
+ Log.i(logTag, message)
+ }
+ }
+
+ @JvmStatic
+ fun logDebug(client: TerminalSessionClient?, logTag: String, message: String) {
+ if (client != null) {
+ client.logDebug(logTag, message)
+ } else {
+ Log.d(logTag, message)
+ }
+ }
+
+ @JvmStatic
+ fun logVerbose(client: TerminalSessionClient?, logTag: String, message: String) {
+ if (client != null) {
+ client.logVerbose(logTag, message)
+ } else {
+ Log.v(logTag, message)
+ }
+ }
+
+ @JvmStatic
+ fun logStackTraceWithMessage(client: TerminalSessionClient?, tag: String, message: String?, throwable: Throwable?) {
+ logError(client, tag, getMessageAndStackTraceString(message, throwable) ?: "")
+ }
+
+ @JvmStatic
+ fun getMessageAndStackTraceString(message: String?, throwable: Throwable?): String? {
+ return when {
+ message == null && throwable == null -> null
+ message != null && throwable != null -> "$message:\n${getStackTraceString(throwable)}"
+ throwable == null -> message
+ else -> getStackTraceString(throwable)
+ }
+ }
+
+ @JvmStatic
+ fun getStackTraceString(throwable: Throwable?): String? {
+ if (throwable == null) return null
+
+ return try {
+ val errors = StringWriter()
+ val pw = PrintWriter(errors)
+ throwable.printStackTrace(pw)
+ pw.close()
+ val result = errors.toString()
+ errors.close()
+ result
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalBuffer.kt b/app/src/main/java/com/termux/terminal/TerminalBuffer.kt
new file mode 100644
index 0000000..7392120
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalBuffer.kt
@@ -0,0 +1,534 @@
+package com.termux.terminal
+
+import java.util.Arrays
+
+/**
+ * A circular buffer of [TerminalRow]s which keeps notes about what is visible on a logical screen and the scroll
+ * history.
+ *
+ * See [externalToInternalRow] for how to map from logical screen rows to array indices.
+ */
+class TerminalBuffer(
+ columns: Int,
+ totalRows: Int,
+ screenRows: Int
+) {
+ @JvmField
+ var mLines: Array
+
+ /** The length of [mLines]. */
+ @JvmField
+ var mTotalRows: Int
+
+ /** The number of rows and columns visible on the screen. */
+ @JvmField
+ var mScreenRows: Int
+
+ @JvmField
+ var mColumns: Int
+
+ /** The number of rows kept in history. */
+ private var mActiveTranscriptRows = 0
+
+ /** The index in the circular buffer where the visible screen starts. */
+ private var mScreenFirstRow = 0
+
+ init {
+ mColumns = columns
+ mTotalRows = totalRows
+ mScreenRows = screenRows
+ mLines = arrayOfNulls(totalRows)
+ blockSet(0, 0, columns, screenRows, ' '.code, TextStyle.NORMAL)
+ }
+
+ fun getTranscriptText(): String {
+ return getSelectedText(0, -activeTranscriptRows, mColumns, mScreenRows).trim()
+ }
+
+ fun getTranscriptTextWithoutJoinedLines(): String {
+ return getSelectedText(0, -activeTranscriptRows, mColumns, mScreenRows, false).trim()
+ }
+
+ fun getTranscriptTextWithFullLinesJoined(): String {
+ return getSelectedText(0, -activeTranscriptRows, mColumns, mScreenRows, joinBackLines = true, joinFullLines = true).trim()
+ }
+
+ fun getSelectedText(selX1: Int, selY1: Int, selX2: Int, selY2: Int): String {
+ return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines = true)
+ }
+
+ fun getSelectedText(selX1: Int, selY1: Int, selX2: Int, selY2: Int, joinBackLines: Boolean): String {
+ return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, joinFullLines = false)
+ }
+
+ fun getSelectedText(
+ selX1: Int,
+ selY1: Int,
+ selX2: Int,
+ selY2: Int,
+ joinBackLines: Boolean,
+ joinFullLines: Boolean
+ ): String {
+ val builder = StringBuilder()
+ val columns = mColumns
+
+ var y1 = selY1
+ var y2 = selY2
+ if (y1 < -activeTranscriptRows) y1 = -activeTranscriptRows
+ if (y2 >= mScreenRows) y2 = mScreenRows - 1
+
+ for (row in y1..y2) {
+ val x1 = if (row == y1) selX1 else 0
+ var x2: Int
+ if (row == y2) {
+ x2 = selX2 + 1
+ if (x2 > columns) x2 = columns
+ } else {
+ x2 = columns
+ }
+ val lineObject = mLines[externalToInternalRow(row)]!!
+ val x1Index = lineObject.findStartOfColumn(x1)
+ val x2Index = if (x2 < mColumns) lineObject.findStartOfColumn(x2) else lineObject.spaceUsed
+ val finalX2Index = if (x2Index == x1Index) {
+ // Selected the start of a wide character.
+ lineObject.findStartOfColumn(x2 + 1)
+ } else {
+ x2Index
+ }
+ val line = lineObject.mText
+ var lastPrintingCharIndex = -1
+ val rowLineWrap = getLineWrap(row)
+ if (rowLineWrap && x2 == columns) {
+ // If the line was wrapped, we shouldn't lose trailing space:
+ lastPrintingCharIndex = finalX2Index - 1
+ } else {
+ for (i in x1Index until finalX2Index) {
+ val c = line[i]
+ if (c != ' ') lastPrintingCharIndex = i
+ }
+ }
+
+ val len = lastPrintingCharIndex - x1Index + 1
+ if (lastPrintingCharIndex != -1 && len > 0)
+ builder.append(line, x1Index, len)
+
+ val lineFillsWidth = lastPrintingCharIndex == finalX2Index - 1
+ if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
+ && row < y2 && row < mScreenRows - 1
+ ) builder.append('\n')
+ }
+ return builder.toString()
+ }
+
+ fun getWordAtLocation(x: Int, y: Int): String {
+ // Set y1 and y2 to the lines where the wrapped line starts and ends.
+ // I.e. if a line that is wrapped to 3 lines starts at line 4, and this
+ // is called with y=5, then y1 would be set to 4 and y2 would be set to 6.
+ var y1 = y
+ var y2 = y
+ while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, joinBackLines = true, joinFullLines = true).contains("\n")) {
+ y1--
+ }
+ while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, joinBackLines = true, joinFullLines = true).contains("\n")) {
+ y2++
+ }
+
+ // Get the text for the whole wrapped line
+ val text = getSelectedText(0, y1, mColumns, y2, joinBackLines = true, joinFullLines = true)
+ // The index of x in text
+ val textOffset = (y - y1) * mColumns + x
+
+ if (textOffset >= text.length) {
+ // The click was to the right of the last word on the line, so
+ // there's no word to return
+ return ""
+ }
+
+ // Set x1 and x2 to the indices of the last space before x and the
+ // first space after x in text respectively
+ val x1 = text.lastIndexOf(' ', textOffset)
+ var x2 = text.indexOf(' ', textOffset)
+ if (x2 == -1) {
+ x2 = text.length
+ }
+
+ if (x1 == x2) {
+ // The click was on a space, so there's no word to return
+ return ""
+ }
+ return text.substring(x1 + 1, x2)
+ }
+
+ val activeTranscriptRows: Int
+ get() = mActiveTranscriptRows
+
+ val activeRows: Int
+ get() = mActiveTranscriptRows + mScreenRows
+
+ /**
+ * Convert a row value from the public external coordinate system to our internal private coordinate system.
+ *
+ * ```
+ * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+ * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+ * mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+ *
+ * External ↔ Internal:
+ *
+ * [ ... ] [ ... ]
+ * [ -mActiveTranscriptRows ] [ mScreenFirstRow - mActiveTranscriptRows ]
+ * [ ... ] [ ... ]
+ * [ 0 (visible screen starts here) ] ↔ [ mScreenFirstRow ]
+ * [ ... ] [ ... ]
+ * [ mScreenRows-1 ] [ mScreenFirstRow + mScreenRows-1 ]
+ * ```
+ *
+ * @param externalRow a row in the external coordinate system.
+ * @return The row corresponding to the input argument in the private coordinate system.
+ */
+ fun externalToInternalRow(externalRow: Int): Int {
+ if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows)
+ throw IllegalArgumentException("extRow=$externalRow, mScreenRows=$mScreenRows, mActiveTranscriptRows=$mActiveTranscriptRows")
+ val internalRow = mScreenFirstRow + externalRow
+ return if (internalRow < 0) (mTotalRows + internalRow) else (internalRow % mTotalRows)
+ }
+
+ fun setLineWrap(row: Int) {
+ mLines[externalToInternalRow(row)]!!.mLineWrap = true
+ }
+
+ fun getLineWrap(row: Int): Boolean {
+ return mLines[externalToInternalRow(row)]!!.mLineWrap
+ }
+
+ fun clearLineWrap(row: Int) {
+ mLines[externalToInternalRow(row)]!!.mLineWrap = false
+ }
+
+ /**
+ * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
+ * change or the rows expand (that is, it only works when shrinking the number of rows).
+ *
+ * @param newColumns The number of columns the screen should have.
+ * @param newRows The number of rows the screen should have.
+ * @param cursor An int[2] containing the (column, row) cursor location.
+ */
+ fun resize(
+ newColumns: Int,
+ newRows: Int,
+ newTotalRows: Int,
+ cursor: IntArray,
+ currentStyle: Long,
+ altScreen: Boolean
+ ) {
+ // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000):
+ if (newColumns == mColumns && newRows <= mTotalRows) {
+ // Fast resize where just the rows changed.
+ var shiftDownOfTopRow = mScreenRows - newRows
+ if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) {
+ // Shrinking. Check if we can skip blank rows at bottom below cursor.
+ for (i in mScreenRows - 1 downTo 1) {
+ if (cursor[1] >= i) break
+ val r = externalToInternalRow(i)
+ if (mLines[r] == null || mLines[r]!!.isBlank()) {
+ if (--shiftDownOfTopRow == 0) break
+ }
+ }
+ } else if (shiftDownOfTopRow < 0) {
+ // Negative shift down = expanding. Only move screen up if there is transcript to show:
+ val actualShift = maxOf(shiftDownOfTopRow, -mActiveTranscriptRows)
+ if (shiftDownOfTopRow != actualShift) {
+ // The new lines revealed by the resizing are not all from the transcript. Blank the below ones.
+ for (i in 0 until actualShift - shiftDownOfTopRow)
+ allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle)
+ shiftDownOfTopRow = actualShift
+ }
+ }
+ mScreenFirstRow += shiftDownOfTopRow
+ mScreenFirstRow = if (mScreenFirstRow < 0) (mScreenFirstRow + mTotalRows) else (mScreenFirstRow % mTotalRows)
+ mTotalRows = newTotalRows
+ mActiveTranscriptRows = if (altScreen) 0 else maxOf(0, mActiveTranscriptRows + shiftDownOfTopRow)
+ cursor[1] -= shiftDownOfTopRow
+ mScreenRows = newRows
+ } else {
+ // Copy away old state and update new:
+ val oldLines = mLines
+ mLines = arrayOfNulls(newTotalRows)
+ for (i in 0 until newTotalRows)
+ mLines[i] = TerminalRow(newColumns, currentStyle)
+
+ val oldActiveTranscriptRows = mActiveTranscriptRows
+ val oldScreenFirstRow = mScreenFirstRow
+ val oldScreenRows = mScreenRows
+ val oldTotalRows = mTotalRows
+ mTotalRows = newTotalRows
+ mScreenRows = newRows
+ mActiveTranscriptRows = 0
+ mScreenFirstRow = 0
+ mColumns = newColumns
+
+ var newCursorRow = -1
+ var newCursorColumn = -1
+ val oldCursorRow = cursor[1]
+ val oldCursorColumn = cursor[0]
+ var newCursorPlaced = false
+
+ var currentOutputExternalRow = 0
+ var currentOutputExternalColumn = 0
+
+ // Loop over every character in the initial state.
+ // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we
+ // keep track how many blank lines we have skipped if we later on find a non-blank line.
+ var skippedBlankLines = 0
+ for (externalOldRow in -oldActiveTranscriptRows until oldScreenRows) {
+ // Do what externalToInternalRow() does but for the old state:
+ var internalOldRow = oldScreenFirstRow + externalOldRow
+ internalOldRow = if (internalOldRow < 0) (oldTotalRows + internalOldRow) else (internalOldRow % oldTotalRows)
+
+ val oldLine = oldLines[internalOldRow]
+ val cursorAtThisRow = externalOldRow == oldCursorRow
+ // The cursor may only be on a non-null line, which we should not skip:
+ if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
+ skippedBlankLines++
+ continue
+ } else if (skippedBlankLines > 0) {
+ // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines.
+ for (i in 0 until skippedBlankLines) {
+ if (currentOutputExternalRow == mScreenRows - 1) {
+ scrollDownOneLine(0, mScreenRows, currentStyle)
+ } else {
+ currentOutputExternalRow++
+ }
+ currentOutputExternalColumn = 0
+ }
+ skippedBlankLines = 0
+ }
+
+ var lastNonSpaceIndex = 0
+ var justToCursor = false
+ if (cursorAtThisRow || oldLine.mLineWrap) {
+ // Take the whole line, either because of cursor on it, or if line wrapping.
+ lastNonSpaceIndex = oldLine.spaceUsed
+ if (cursorAtThisRow) justToCursor = true
+ } else {
+ for (i in 0 until oldLine.spaceUsed) {
+ // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices
+ if (oldLine.mText[i] != ' ' /* || oldLine.mStyle[i] != currentStyle */)
+ lastNonSpaceIndex = i + 1
+ }
+ }
+
+ var currentOldCol = 0
+ var styleAtCol: Long = 0
+ var i = 0
+ while (i < lastNonSpaceIndex) {
+ // Note that looping over java character, not cells.
+ val c = oldLine.mText[i]
+ val codePoint = if (Character.isHighSurrogate(c)) Character.toCodePoint(c, oldLine.mText[++i]) else c.code
+ val displayWidth = WcWidth.width(codePoint)
+ // Use the last style if this is a zero-width character:
+ if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol)
+
+ // Line wrap as necessary:
+ if (currentOutputExternalColumn + displayWidth > mColumns) {
+ setLineWrap(currentOutputExternalRow)
+ if (currentOutputExternalRow == mScreenRows - 1) {
+ if (newCursorPlaced) newCursorRow--
+ scrollDownOneLine(0, mScreenRows, currentStyle)
+ } else {
+ currentOutputExternalRow++
+ }
+ currentOutputExternalColumn = 0
+ }
+
+ val offsetDueToCombiningChar = if (displayWidth <= 0 && currentOutputExternalColumn > 0) 1 else 0
+ val outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar
+ setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol)
+
+ if (displayWidth > 0) {
+ if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
+ newCursorColumn = currentOutputExternalColumn
+ newCursorRow = currentOutputExternalRow
+ newCursorPlaced = true
+ }
+ currentOldCol += displayWidth
+ currentOutputExternalColumn += displayWidth
+ if (justToCursor && newCursorPlaced) break
+ }
+ i++
+ }
+ // Old row has been copied. Check if we need to insert newline if old line was not wrapping:
+ if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) {
+ if (currentOutputExternalRow == mScreenRows - 1) {
+ if (newCursorPlaced) newCursorRow--
+ scrollDownOneLine(0, mScreenRows, currentStyle)
+ } else {
+ currentOutputExternalRow++
+ }
+ currentOutputExternalColumn = 0
+ }
+ }
+
+ cursor[0] = newCursorColumn
+ cursor[1] = newCursorRow
+ }
+
+ // Handle cursor scrolling off screen:
+ if (cursor[0] < 0 || cursor[1] < 0) {
+ cursor[0] = 0
+ cursor[1] = 0
+ }
+ }
+
+ /**
+ * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
+ * into account.
+ *
+ * @param srcInternal The first line to be copied.
+ * @param len The number of lines to be copied.
+ */
+ private fun blockCopyLinesDown(srcInternal: Int, len: Int) {
+ if (len == 0) return
+ val totalRows = mTotalRows
+
+ val start = len - 1
+ // Save away line to be overwritten:
+ val lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]
+ // Do the copy from bottom to top.
+ for (i in start downTo 0)
+ mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]
+ // Put back overwritten line, now above the block:
+ mLines[srcInternal % totalRows] = lineToBeOverWritten
+ }
+
+ /**
+ * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
+ *
+ * @param topMargin First line that is scrolled.
+ * @param bottomMargin One line after the last line that is scrolled.
+ * @param style the style for the newly exposed line.
+ */
+ fun scrollDownOneLine(topMargin: Int, bottomMargin: Int, style: Long) {
+ if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows)
+ throw IllegalArgumentException("topMargin=$topMargin, bottomMargin=$bottomMargin, mScreenRows=$mScreenRows")
+
+ // Copy the fixed topMargin lines one line down so that they remain on screen in same position:
+ blockCopyLinesDown(mScreenFirstRow, topMargin)
+ // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same
+ // position:
+ blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin)
+
+ // Update the screen location in the ring buffer:
+ mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows
+ // Note that the history has grown if not already full:
+ if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++
+
+ // Blank the newly revealed line above the bottom margin:
+ val blankRow = externalToInternalRow(bottomMargin - 1)
+ if (mLines[blankRow] == null) {
+ mLines[blankRow] = TerminalRow(mColumns, style)
+ } else {
+ mLines[blankRow]!!.clear(style)
+ }
+ }
+
+ /**
+ * Block copy characters from one position in the screen to another. The two positions can overlap. All characters
+ * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
+ * be thrown.
+ *
+ * @param sx source X coordinate
+ * @param sy source Y coordinate
+ * @param w width
+ * @param h height
+ * @param dx destination X coordinate
+ * @param dy destination Y coordinate
+ */
+ fun blockCopy(sx: Int, sy: Int, w: Int, h: Int, dx: Int, dy: Int) {
+ if (w == 0) return
+ if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows)
+ throw IllegalArgumentException()
+ val copyingUp = sy > dy
+ for (y in 0 until h) {
+ val y2 = if (copyingUp) y else (h - (y + 1))
+ val sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2))
+ allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx)
+ }
+ }
+
+ /**
+ * Block set characters. All characters must be within the bounds of the screen, or else and
+ * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
+ * of characters.
+ */
+ fun blockSet(sx: Int, sy: Int, w: Int, h: Int, `val`: Int, style: Long) {
+ if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) {
+ throw IllegalArgumentException(
+ "Illegal arguments! blockSet($sx, $sy, $w, $h, $`val`, $mColumns, $mScreenRows)"
+ )
+ }
+ for (y in 0 until h)
+ for (x in 0 until w)
+ setChar(sx + x, sy + y, `val`, style)
+ }
+
+ fun allocateFullLineIfNecessary(row: Int): TerminalRow {
+ return mLines[row] ?: TerminalRow(mColumns, 0).also { mLines[row] = it }
+ }
+
+ fun setChar(column: Int, row: Int, codePoint: Int, style: Long) {
+ if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns)
+ throw IllegalArgumentException("TerminalBuffer.setChar(): row=$row, column=$column, mScreenRows=$mScreenRows, mColumns=$mColumns")
+ val internalRow = externalToInternalRow(row)
+ allocateFullLineIfNecessary(internalRow).setChar(column, codePoint, style)
+ }
+
+ fun getStyleAt(externalRow: Int, column: Int): Long {
+ return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column)
+ }
+
+ /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
+ fun setOrClearEffect(
+ bits: Int,
+ setOrClear: Boolean,
+ reverse: Boolean,
+ rectangular: Boolean,
+ leftMargin: Int,
+ rightMargin: Int,
+ top: Int,
+ left: Int,
+ bottom: Int,
+ right: Int
+ ) {
+ for (y in top until bottom) {
+ val line = mLines[externalToInternalRow(y)]!!
+ val startOfLine = if (rectangular || y == top) left else leftMargin
+ val endOfLine = if (rectangular || y + 1 == bottom) right else rightMargin
+ for (x in startOfLine until endOfLine) {
+ val currentStyle = line.getStyle(x)
+ val foreColor = TextStyle.decodeForeColor(currentStyle)
+ val backColor = TextStyle.decodeBackColor(currentStyle)
+ var effect = TextStyle.decodeEffect(currentStyle)
+ effect = if (reverse) {
+ // Clear out the bits to reverse and add them back in reversed:
+ (effect and bits.inv()) or (bits and effect.inv())
+ } else if (setOrClear) {
+ effect or bits
+ } else {
+ effect and bits.inv()
+ }
+ line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect)
+ }
+ }
+ }
+
+ fun clearTranscript() {
+ if (mScreenFirstRow < mActiveTranscriptRows) {
+ Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null)
+ Arrays.fill(mLines, 0, mScreenFirstRow, null)
+ } else {
+ Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null)
+ }
+ mActiveTranscriptRows = 0
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalColorScheme.kt b/app/src/main/java/com/termux/terminal/TerminalColorScheme.kt
new file mode 100644
index 0000000..5e73b3f
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalColorScheme.kt
@@ -0,0 +1,151 @@
+package com.termux.terminal
+
+import java.util.Properties
+
+/**
+ * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using
+ * Operating System Control (OSC) sequences.
+ *
+ * @see TerminalColors
+ */
+class TerminalColorScheme {
+
+ @JvmField
+ val mDefaultColors = IntArray(TextStyle.NUM_INDEXED_COLORS)
+
+ init {
+ reset()
+ }
+
+ private fun reset() {
+ System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS)
+ }
+
+ fun updateWith(props: Properties) {
+ reset()
+ var cursorPropExists = false
+ for ((key, value) in props.entries) {
+ val keyStr = key as String
+ val valueStr = value as String
+ val colorIndex: Int
+
+ when {
+ keyStr == "foreground" -> colorIndex = TextStyle.COLOR_INDEX_FOREGROUND
+ keyStr == "background" -> colorIndex = TextStyle.COLOR_INDEX_BACKGROUND
+ keyStr == "cursor" -> {
+ colorIndex = TextStyle.COLOR_INDEX_CURSOR
+ cursorPropExists = true
+ }
+ keyStr.startsWith("color") -> {
+ colorIndex = try {
+ keyStr.substring(5).toInt()
+ } catch (e: NumberFormatException) {
+ throw IllegalArgumentException("Invalid property: '$keyStr'")
+ }
+ }
+ else -> throw IllegalArgumentException("Invalid property: '$keyStr'")
+ }
+
+ val colorValue = TerminalColors.parse(valueStr)
+ if (colorValue == 0) {
+ throw IllegalArgumentException("Property '$keyStr' has invalid color: '$valueStr'")
+ }
+
+ mDefaultColors[colorIndex] = colorValue
+ }
+
+ if (!cursorPropExists) {
+ setCursorColorForBackground()
+ }
+ }
+
+ /**
+ * If the "cursor" color is not set by user, we need to decide on the appropriate color that will
+ * be visible on the current terminal background. White will not be visible on light backgrounds
+ * and black won't be visible on dark backgrounds. So we find the perceived brightness of the
+ * background color and if its below the threshold (too dark), we use white cursor and if its
+ * above (too bright), we use black cursor.
+ */
+ fun setCursorColorForBackground() {
+ val backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND]
+ val brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor)
+ if (brightness > 0) {
+ mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = if (brightness < 130) {
+ 0xffffffff.toInt()
+ } else {
+ 0xff000000.toInt()
+ }
+ }
+ }
+
+ companion object {
+ /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */
+ private val DEFAULT_COLORSCHEME = intArrayOf(
+ // 16 original colors. First 8 are dim.
+ 0xff000000.toInt(), // black
+ 0xffcd0000.toInt(), // dim red
+ 0xff00cd00.toInt(), // dim green
+ 0xffcdcd00.toInt(), // dim yellow
+ 0xff6495ed.toInt(), // dim blue
+ 0xffcd00cd.toInt(), // dim magenta
+ 0xff00cdcd.toInt(), // dim cyan
+ 0xffe5e5e5.toInt(), // dim white
+ // Second 8 are bright:
+ 0xff7f7f7f.toInt(), // medium grey
+ 0xffff0000.toInt(), // bright red
+ 0xff00ff00.toInt(), // bright green
+ 0xffffff00.toInt(), // bright yellow
+ 0xff5c5cff.toInt(), // light blue
+ 0xffff00ff.toInt(), // bright magenta
+ 0xff00ffff.toInt(), // bright cyan
+ 0xffffffff.toInt(), // bright white
+
+ // 216 color cube, six shades of each color:
+ 0xff000000.toInt(), 0xff00005f.toInt(), 0xff000087.toInt(), 0xff0000af.toInt(), 0xff0000d7.toInt(), 0xff0000ff.toInt(),
+ 0xff005f00.toInt(), 0xff005f5f.toInt(), 0xff005f87.toInt(), 0xff005faf.toInt(), 0xff005fd7.toInt(), 0xff005fff.toInt(),
+ 0xff008700.toInt(), 0xff00875f.toInt(), 0xff008787.toInt(), 0xff0087af.toInt(), 0xff0087d7.toInt(), 0xff0087ff.toInt(),
+ 0xff00af00.toInt(), 0xff00af5f.toInt(), 0xff00af87.toInt(), 0xff00afaf.toInt(), 0xff00afd7.toInt(), 0xff00afff.toInt(),
+ 0xff00d700.toInt(), 0xff00d75f.toInt(), 0xff00d787.toInt(), 0xff00d7af.toInt(), 0xff00d7d7.toInt(), 0xff00d7ff.toInt(),
+ 0xff00ff00.toInt(), 0xff00ff5f.toInt(), 0xff00ff87.toInt(), 0xff00ffaf.toInt(), 0xff00ffd7.toInt(), 0xff00ffff.toInt(),
+ 0xff5f0000.toInt(), 0xff5f005f.toInt(), 0xff5f0087.toInt(), 0xff5f00af.toInt(), 0xff5f00d7.toInt(), 0xff5f00ff.toInt(),
+ 0xff5f5f00.toInt(), 0xff5f5f5f.toInt(), 0xff5f5f87.toInt(), 0xff5f5faf.toInt(), 0xff5f5fd7.toInt(), 0xff5f5fff.toInt(),
+ 0xff5f8700.toInt(), 0xff5f875f.toInt(), 0xff5f8787.toInt(), 0xff5f87af.toInt(), 0xff5f87d7.toInt(), 0xff5f87ff.toInt(),
+ 0xff5faf00.toInt(), 0xff5faf5f.toInt(), 0xff5faf87.toInt(), 0xff5fafaf.toInt(), 0xff5fafd7.toInt(), 0xff5fafff.toInt(),
+ 0xff5fd700.toInt(), 0xff5fd75f.toInt(), 0xff5fd787.toInt(), 0xff5fd7af.toInt(), 0xff5fd7d7.toInt(), 0xff5fd7ff.toInt(),
+ 0xff5fff00.toInt(), 0xff5fff5f.toInt(), 0xff5fff87.toInt(), 0xff5fffaf.toInt(), 0xff5fffd7.toInt(), 0xff5fffff.toInt(),
+ 0xff870000.toInt(), 0xff87005f.toInt(), 0xff870087.toInt(), 0xff8700af.toInt(), 0xff8700d7.toInt(), 0xff8700ff.toInt(),
+ 0xff875f00.toInt(), 0xff875f5f.toInt(), 0xff875f87.toInt(), 0xff875faf.toInt(), 0xff875fd7.toInt(), 0xff875fff.toInt(),
+ 0xff878700.toInt(), 0xff87875f.toInt(), 0xff878787.toInt(), 0xff8787af.toInt(), 0xff8787d7.toInt(), 0xff8787ff.toInt(),
+ 0xff87af00.toInt(), 0xff87af5f.toInt(), 0xff87af87.toInt(), 0xff87afaf.toInt(), 0xff87afd7.toInt(), 0xff87afff.toInt(),
+ 0xff87d700.toInt(), 0xff87d75f.toInt(), 0xff87d787.toInt(), 0xff87d7af.toInt(), 0xff87d7d7.toInt(), 0xff87d7ff.toInt(),
+ 0xff87ff00.toInt(), 0xff87ff5f.toInt(), 0xff87ff87.toInt(), 0xff87ffaf.toInt(), 0xff87ffd7.toInt(), 0xff87ffff.toInt(),
+ 0xffaf0000.toInt(), 0xffaf005f.toInt(), 0xffaf0087.toInt(), 0xffaf00af.toInt(), 0xffaf00d7.toInt(), 0xffaf00ff.toInt(),
+ 0xffaf5f00.toInt(), 0xffaf5f5f.toInt(), 0xffaf5f87.toInt(), 0xffaf5faf.toInt(), 0xffaf5fd7.toInt(), 0xffaf5fff.toInt(),
+ 0xffaf8700.toInt(), 0xffaf875f.toInt(), 0xffaf8787.toInt(), 0xffaf87af.toInt(), 0xffaf87d7.toInt(), 0xffaf87ff.toInt(),
+ 0xffafaf00.toInt(), 0xffafaf5f.toInt(), 0xffafaf87.toInt(), 0xffafafaf.toInt(), 0xffafafd7.toInt(), 0xffafafff.toInt(),
+ 0xffafd700.toInt(), 0xffafd75f.toInt(), 0xffafd787.toInt(), 0xffafd7af.toInt(), 0xffafd7d7.toInt(), 0xffafd7ff.toInt(),
+ 0xffafff00.toInt(), 0xffafff5f.toInt(), 0xffafff87.toInt(), 0xffafffaf.toInt(), 0xffafffd7.toInt(), 0xffafffff.toInt(),
+ 0xffd70000.toInt(), 0xffd7005f.toInt(), 0xffd70087.toInt(), 0xffd700af.toInt(), 0xffd700d7.toInt(), 0xffd700ff.toInt(),
+ 0xffd75f00.toInt(), 0xffd75f5f.toInt(), 0xffd75f87.toInt(), 0xffd75faf.toInt(), 0xffd75fd7.toInt(), 0xffd75fff.toInt(),
+ 0xffd78700.toInt(), 0xffd7875f.toInt(), 0xffd78787.toInt(), 0xffd787af.toInt(), 0xffd787d7.toInt(), 0xffd787ff.toInt(),
+ 0xffd7af00.toInt(), 0xffd7af5f.toInt(), 0xffd7af87.toInt(), 0xffd7afaf.toInt(), 0xffd7afd7.toInt(), 0xffd7afff.toInt(),
+ 0xffd7d700.toInt(), 0xffd7d75f.toInt(), 0xffd7d787.toInt(), 0xffd7d7af.toInt(), 0xffd7d7d7.toInt(), 0xffd7d7ff.toInt(),
+ 0xffd7ff00.toInt(), 0xffd7ff5f.toInt(), 0xffd7ff87.toInt(), 0xffd7ffaf.toInt(), 0xffd7ffd7.toInt(), 0xffd7ffff.toInt(),
+ 0xffff0000.toInt(), 0xffff005f.toInt(), 0xffff0087.toInt(), 0xffff00af.toInt(), 0xffff00d7.toInt(), 0xffff00ff.toInt(),
+ 0xffff5f00.toInt(), 0xffff5f5f.toInt(), 0xffff5f87.toInt(), 0xffff5faf.toInt(), 0xffff5fd7.toInt(), 0xffff5fff.toInt(),
+ 0xffff8700.toInt(), 0xffff875f.toInt(), 0xffff8787.toInt(), 0xffff87af.toInt(), 0xffff87d7.toInt(), 0xffff87ff.toInt(),
+ 0xffffaf00.toInt(), 0xffffaf5f.toInt(), 0xffffaf87.toInt(), 0xffffafaf.toInt(), 0xffffafd7.toInt(), 0xffffafff.toInt(),
+ 0xffffd700.toInt(), 0xffffd75f.toInt(), 0xffffd787.toInt(), 0xffffd7af.toInt(), 0xffffd7d7.toInt(), 0xffffd7ff.toInt(),
+ 0xffffff00.toInt(), 0xffffff5f.toInt(), 0xffffff87.toInt(), 0xffffffaf.toInt(), 0xffffffd7.toInt(), 0xffffffff.toInt(),
+
+ // 24 grey scale ramp:
+ 0xff080808.toInt(), 0xff121212.toInt(), 0xff1c1c1c.toInt(), 0xff262626.toInt(), 0xff303030.toInt(), 0xff3a3a3a.toInt(),
+ 0xff444444.toInt(), 0xff4e4e4e.toInt(), 0xff585858.toInt(), 0xff626262.toInt(), 0xff6c6c6c.toInt(), 0xff767676.toInt(),
+ 0xff808080.toInt(), 0xff8a8a8a.toInt(), 0xff949494.toInt(), 0xff9e9e9e.toInt(), 0xffa8a8a8.toInt(), 0xffb2b2b2.toInt(),
+ 0xffbcbcbc.toInt(), 0xffc6c6c6.toInt(), 0xffd0d0d0.toInt(), 0xffdadada.toInt(), 0xffe4e4e4.toInt(), 0xffeeeeee.toInt(),
+
+ // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR:
+ 0xffffffff.toInt(), 0xff000000.toInt(), 0xffffffff.toInt()
+ )
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalColors.kt b/app/src/main/java/com/termux/terminal/TerminalColors.kt
new file mode 100644
index 0000000..1f2fd96
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalColors.kt
@@ -0,0 +1,108 @@
+package com.termux.terminal
+
+import android.graphics.Color
+import kotlin.math.floor
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/** Current terminal colors (if different from default). */
+class TerminalColors {
+
+ /**
+ * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC
+ * 4 control sequence.
+ */
+ @JvmField
+ val mCurrentColors = IntArray(TextStyle.NUM_INDEXED_COLORS)
+
+ init {
+ reset()
+ }
+
+ /** Reset a particular indexed color with the default color from the color theme. */
+ fun reset(index: Int) {
+ mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]
+ }
+
+ /** Reset all indexed colors with the default color from the color theme. */
+ fun reset() {
+ System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS)
+ }
+
+ /** Try parse a color from a text parameter and into a specified index. */
+ fun tryParseColor(intoIndex: Int, textParameter: String?) {
+ val c = parse(textParameter)
+ if (c != 0) mCurrentColors[intoIndex] = c
+ }
+
+ companion object {
+ /** Static data - a bit ugly but ok for now. */
+ @JvmField
+ val COLOR_SCHEME = TerminalColorScheme()
+
+ /**
+ * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html
+ *
+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed.
+ */
+ @JvmStatic
+ fun parse(c: String?): Int {
+ if (c == null) return 0
+ try {
+ val skipInitial: Int
+ val skipBetween: Int
+ when {
+ c[0] == '#' -> {
+ // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits.
+ skipInitial = 1
+ skipBetween = 0
+ }
+ c.startsWith("rgb:") -> {
+ // rgb:// where , , := h | hh | hhh | hhhh. Scaled.
+ skipInitial = 4
+ skipBetween = 1
+ }
+ else -> return 0
+ }
+ val charsForColors = c.length - skipInitial - 2 * skipBetween
+ if (charsForColors % 3 != 0) return 0 // Unequal lengths.
+ val componentLength = charsForColors / 3
+ val mult = 255.0 / (2.0.pow(componentLength * 4.0) - 1)
+
+ var currentPosition = skipInitial
+ val rString = c.substring(currentPosition, currentPosition + componentLength)
+ currentPosition += componentLength + skipBetween
+ val gString = c.substring(currentPosition, currentPosition + componentLength)
+ currentPosition += componentLength + skipBetween
+ val bString = c.substring(currentPosition, currentPosition + componentLength)
+
+ val r = (Integer.parseInt(rString, 16) * mult).toInt()
+ val g = (Integer.parseInt(gString, 16) * mult).toInt()
+ val b = (Integer.parseInt(bString, 16) * mult).toInt()
+ return (0xFF shl 24) or (r shl 16) or (g shl 8) or b
+ } catch (e: Exception) {
+ return 0
+ }
+ }
+
+ /**
+ * Get the perceived brightness of the color based on its RGB components.
+ *
+ * https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx
+ * http://alienryderflex.com/hsp.html
+ *
+ * @param color The color code int.
+ * @return Returns value between 0-255.
+ */
+ @JvmStatic
+ fun getPerceivedBrightnessOfColor(color: Int): Int {
+ return floor(
+ sqrt(
+ Color.red(color).toDouble().pow(2.0) * 0.241 +
+ Color.green(color).toDouble().pow(2.0) * 0.691 +
+ Color.blue(color).toDouble().pow(2.0) * 0.068
+ )
+ ).toInt()
+ }
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalEmulator.kt b/app/src/main/java/com/termux/terminal/TerminalEmulator.kt
new file mode 100644
index 0000000..deb0496
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalEmulator.kt
@@ -0,0 +1,1838 @@
+package com.termux.terminal
+
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+import java.util.Arrays
+import java.util.Locale
+import java.util.Objects
+import java.util.Stack
+
+/**
+ * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window
+ * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal.
+ *
+ * References:
+ * - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
+ * - http://en.wikipedia.org/wiki/ANSI_escape_code
+ * - http://man.he.net/man4/console_codes
+ * - http://bazaar.launchpad.net/~leonerd/libvterm/trunk/view/head:/src/state.c
+ * - http://www.columbia.edu/~kermit/k95manual/iso2022.html
+ * - http://www.vt100.net/docs/vt510-rm/chapter4
+ * - http://en.wikipedia.org/wiki/ISO/IEC_2022 - for 7-bit and 8-bit GL GR explanation
+ * - http://bjh21.me.uk/all-escapes/all-escapes.txt - extensive!
+ * - http://woldlab.caltech.edu/~diane/kde4.10/workingdir/kubuntu/konsole/doc/developer/old-documents/VT100/techref.html
+ */
+class TerminalEmulator(
+ private val mSession: TerminalOutput,
+ columns: Int,
+ rows: Int,
+ cellWidthPixels: Int,
+ cellHeightPixels: Int,
+ transcriptRows: Int?,
+ client: TerminalSessionClient?
+) {
+ companion object {
+ /** Log unknown or unimplemented escape sequences received from the shell process. */
+ private const val LOG_ESCAPE_SEQUENCES = false
+
+ @JvmField val MOUSE_LEFT_BUTTON = 0
+ /** Mouse moving while having left mouse button pressed. */
+ @JvmField val MOUSE_LEFT_BUTTON_MOVED = 32
+ @JvmField val MOUSE_WHEELUP_BUTTON = 64
+ @JvmField val MOUSE_WHEELDOWN_BUTTON = 65
+
+ /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */
+ @JvmField val UNICODE_REPLACEMENT_CHAR = 0xFFFD
+
+ /** Escape processing: Not currently in an escape sequence. */
+ private const val ESC_NONE = 0
+ /** Escape processing: Have seen an ESC character - proceed to [doEsc] */
+ private const val ESC = 1
+ /** Escape processing: Have seen ESC POUND */
+ private const val ESC_POUND = 2
+ /** Escape processing: Have seen ESC and a character-set-select ( char */
+ private const val ESC_SELECT_LEFT_PAREN = 3
+ /** Escape processing: Have seen ESC and a character-set-select ) char */
+ private const val ESC_SELECT_RIGHT_PAREN = 4
+ /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */
+ private const val ESC_CSI = 6
+ /** Escape processing: ESC [ ? */
+ private const val ESC_CSI_QUESTIONMARK = 7
+ /** Escape processing: ESC [ $ */
+ private const val ESC_CSI_DOLLAR = 8
+ /** Escape processing: ESC % */
+ private const val ESC_PERCENT = 9
+ /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */
+ private const val ESC_OSC = 10
+ /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */
+ private const val ESC_OSC_ESC = 11
+ /** Escape processing: ESC [ > */
+ private const val ESC_CSI_BIGGERTHAN = 12
+ /** Escape procession: "ESC P" or Device Control String (DCS) */
+ private const val ESC_P = 13
+ /** Escape processing: CSI > */
+ private const val ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14
+ /** Escape processing: CSI ARGS ' ' */
+ private const val ESC_CSI_ARGS_SPACE = 15
+ /** Escape processing: CSI ARGS '*' */
+ private const val ESC_CSI_ARGS_ASTERIX = 16
+ /** Escape processing: CSI " */
+ private const val ESC_CSI_DOUBLE_QUOTE = 17
+ /** Escape processing: CSI ' */
+ private const val ESC_CSI_SINGLE_QUOTE = 18
+ /** Escape processing: CSI ! */
+ private const val ESC_CSI_EXCLAMATION = 19
+ /** Escape processing: "ESC _" or Application Program Command (APC). */
+ private const val ESC_APC = 20
+ /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */
+ private const val ESC_APC_ESCAPE = 21
+ /** Escape processing: ESC [ */
+ private const val ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22
+ /** Escape processing: ESC [ */
+ private const val ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23
+
+ /** The number of parameter arguments including colon separated sub-parameters. */
+ private const val MAX_ESCAPE_PARAMETERS = 32
+
+ /** Needs to be large enough to contain reasonable OSC 52 pastes. */
+ private const val MAX_OSC_STRING_LENGTH = 8192
+
+ /** DECSET 1 - application cursor keys. */
+ private const val DECSET_BIT_APPLICATION_CURSOR_KEYS = 1
+ private const val DECSET_BIT_REVERSE_VIDEO = 1 shl 1
+ private const val DECSET_BIT_ORIGIN_MODE = 1 shl 2
+ private const val DECSET_BIT_AUTOWRAP = 1 shl 3
+ /** DECSET 25 - if the cursor should be enabled, [isCursorEnabled]. */
+ private const val DECSET_BIT_CURSOR_ENABLED = 1 shl 4
+ private const val DECSET_BIT_APPLICATION_KEYPAD = 1 shl 5
+ /** DECSET 1000 - if to report mouse press&release events. */
+ private const val DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 shl 6
+ /** DECSET 1002 - like 1000, but report moving mouse while pressed. */
+ private const val DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 shl 7
+ /** DECSET 1004 - NOT implemented. */
+ private const val DECSET_BIT_SEND_FOCUS_EVENTS = 1 shl 8
+ /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */
+ private const val DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 shl 9
+ /** DECSET 2004 - see [paste] */
+ private const val DECSET_BIT_BRACKETED_PASTE_MODE = 1 shl 10
+ /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */
+ private const val DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 shl 11
+ /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */
+ private const val DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 shl 12
+
+ /** The number of terminal transcript rows that can be scrolled back to. */
+ @JvmField val TERMINAL_TRANSCRIPT_ROWS_MIN = 100
+ @JvmField val TERMINAL_TRANSCRIPT_ROWS_MAX = 50000
+ @JvmField val DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000
+
+ /* The supported terminal cursor styles. */
+ @JvmField val TERMINAL_CURSOR_STYLE_BLOCK = 0
+ @JvmField val TERMINAL_CURSOR_STYLE_UNDERLINE = 1
+ @JvmField val TERMINAL_CURSOR_STYLE_BAR = 2
+ @JvmField val DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK
+ @JvmField val TERMINAL_CURSOR_STYLES_LIST = arrayOf(TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR)
+
+ private const val LOG_TAG = "TerminalEmulator"
+
+ @JvmStatic
+ fun mapDecSetBitToInternalBit(decsetBit: Int): Int {
+ return when (decsetBit) {
+ 1 -> DECSET_BIT_APPLICATION_CURSOR_KEYS
+ 5 -> DECSET_BIT_REVERSE_VIDEO
+ 6 -> DECSET_BIT_ORIGIN_MODE
+ 7 -> DECSET_BIT_AUTOWRAP
+ 25 -> DECSET_BIT_CURSOR_ENABLED
+ 66 -> DECSET_BIT_APPLICATION_KEYPAD
+ 69 -> DECSET_BIT_LEFTRIGHT_MARGIN_MODE
+ 1000 -> DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE
+ 1002 -> DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT
+ 1004 -> DECSET_BIT_SEND_FOCUS_EVENTS
+ 1006 -> DECSET_BIT_MOUSE_PROTOCOL_SGR
+ 2004 -> DECSET_BIT_BRACKETED_PASTE_MODE
+ else -> -1
+ }
+ }
+ }
+
+ private var mTitle: String? = null
+ private val mTitleStack = Stack()
+
+ /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */
+ private var mCursorRow = 0
+ private var mCursorCol = 0
+
+ /** The number of character rows and columns in the terminal screen. */
+ @JvmField var mRows: Int = rows
+ @JvmField var mColumns: Int = columns
+
+ /** Size of a terminal cell in pixels. */
+ private var mCellWidthPixels: Int = cellWidthPixels
+ private var mCellHeightPixels: Int = cellHeightPixels
+
+ /** The terminal cursor styles. */
+ private var mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE
+
+ /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */
+ private val mMainBuffer: TerminalBuffer
+
+ /**
+ * The alternate screen buffer, exactly as large as the display and contains no additional saved lines.
+ */
+ @JvmField
+ val mAltBuffer: TerminalBuffer
+
+ /** The current screen buffer, pointing at either [mMainBuffer] or [mAltBuffer]. */
+ private var mScreen: TerminalBuffer
+
+ @JvmField
+ var mClient: TerminalSessionClient? = client
+
+ /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */
+ private var mArgIndex = 0
+
+ /** Holds the arguments of the current escape sequence. */
+ private val mArgs = IntArray(MAX_ESCAPE_PARAMETERS)
+
+ /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */
+ private var mArgsSubParamsBitSet = 0
+
+ /** Holds OSC and device control arguments, which can be strings. */
+ private val mOSCOrDeviceControlArgs = StringBuilder()
+
+ private var mContinueSequence = false
+
+ /** The current state of the escape sequence state machine. One of the ESC_* constants. */
+ private var mEscapeState = ESC_NONE
+
+ private val mSavedStateMain = SavedScreenState()
+ private val mSavedStateAlt = SavedScreenState()
+
+ /** http://www.vt100.net/docs/vt102-ug/table5-15.html */
+ private var mUseLineDrawingG0 = false
+ private var mUseLineDrawingG1 = false
+ private var mUseLineDrawingUsesG0 = true
+
+ private var mCurrentDecSetFlags = 0
+ private var mSavedDecSetFlags = 0
+
+ private var mInsertMode = false
+
+ /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */
+ private var mTabStop: BooleanArray
+
+ private var mTopMargin = 0
+ private var mBottomMargin = 0
+ private var mLeftMargin = 0
+ private var mRightMargin = 0
+
+ private var mAboutToAutoWrap = false
+
+ private var mCursorBlinkingEnabled = false
+ private var mCursorBlinkState = false
+
+ @JvmField var mForeColor = 0
+ @JvmField var mBackColor = 0
+ @JvmField var mUnderlineColor = 0
+
+ /** Current [TextStyle] effect. */
+ @JvmField var mEffect = 0
+
+ private var mScrollCounter = 0
+
+ /** If automatic scrolling of terminal is disabled */
+ private var mAutoScrollDisabled = false
+
+ private var mUtf8ToFollow: Byte = 0
+ private var mUtf8Index: Byte = 0
+ private val mUtf8InputBuffer = ByteArray(4)
+ private var mLastEmittedCodePoint = -1
+
+ @JvmField
+ val mColors = TerminalColors()
+
+ init {
+ mScreen = TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows)
+ mMainBuffer = mScreen
+ mAltBuffer = TerminalBuffer(columns, rows, rows)
+ mTabStop = BooleanArray(mColumns)
+ reset()
+ }
+
+ fun updateTerminalSessionClient(client: TerminalSessionClient?) {
+ mClient = client
+ setCursorStyle()
+ setCursorBlinkState(true)
+ }
+
+ fun getScreen(): TerminalBuffer = mScreen
+
+ fun isAlternateBufferActive(): Boolean = mScreen === mAltBuffer
+
+ private fun getTerminalTranscriptRows(transcriptRows: Int?): Int {
+ return if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX)
+ DEFAULT_TERMINAL_TRANSCRIPT_ROWS
+ else
+ transcriptRows
+ }
+
+ fun sendMouseEvent(mouseButton: Int, column: Int, row: Int, pressed: Boolean) {
+ var col = column
+ var r = row
+ if (col < 1) col = 1
+ if (col > mColumns) col = mColumns
+ if (r < 1) r = 1
+ if (r > mRows) r = mRows
+
+ if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) {
+ // Do not send tracking.
+ } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) {
+ mSession.write(String.format("\u001b[<%d;%d;%d" + (if (pressed) 'M' else 'm'), mouseButton, col, r))
+ } else {
+ val button = if (pressed) mouseButton else 3
+ val outOfBounds = col > 255 - 32 || r > 255 - 32
+ if (!outOfBounds) {
+ val data = byteArrayOf('\u001b'.code.toByte(), '['.code.toByte(), 'M'.code.toByte(), (32 + button).toByte(), (32 + col).toByte(), (32 + r).toByte())
+ mSession.write(data, 0, data.size)
+ }
+ }
+ }
+
+ fun resize(columns: Int, rows: Int, cellWidthPixels: Int, cellHeightPixels: Int) {
+ this.mCellWidthPixels = cellWidthPixels
+ this.mCellHeightPixels = cellHeightPixels
+
+ if (mRows == rows && mColumns == columns) {
+ return
+ } else if (columns < 2 || rows < 2) {
+ throw IllegalArgumentException("rows=$rows, columns=$columns")
+ }
+
+ if (mRows != rows) {
+ mRows = rows
+ mTopMargin = 0
+ mBottomMargin = mRows
+ }
+ if (mColumns != columns) {
+ val oldColumns = mColumns
+ mColumns = columns
+ val oldTabStop = mTabStop
+ mTabStop = BooleanArray(mColumns)
+ setDefaultTabStops()
+ val toTransfer = minOf(oldColumns, columns)
+ System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer)
+ mLeftMargin = 0
+ mRightMargin = mColumns
+ }
+
+ resizeScreen()
+ }
+
+ private fun resizeScreen() {
+ val cursor = intArrayOf(mCursorCol, mCursorRow)
+ val newTotalRows = if (mScreen === mAltBuffer) mRows else mMainBuffer.mTotalRows
+ mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive())
+ mCursorCol = cursor[0]
+ mCursorRow = cursor[1]
+ }
+
+ fun getCursorRow(): Int = mCursorRow
+ fun getCursorCol(): Int = mCursorCol
+ fun getCursorStyle(): Int = mCursorStyle
+
+ fun setCursorStyle() {
+ val cursorStyle = mClient?.getTerminalCursorStyle()
+ mCursorStyle = if (cursorStyle == null || !TERMINAL_CURSOR_STYLES_LIST.contains(cursorStyle))
+ DEFAULT_TERMINAL_CURSOR_STYLE
+ else
+ cursorStyle
+ }
+
+ fun isReverseVideo(): Boolean = isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO)
+ fun isCursorEnabled(): Boolean = isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED)
+
+ fun shouldCursorBeVisible(): Boolean {
+ return if (!isCursorEnabled()) false else if (mCursorBlinkingEnabled) mCursorBlinkState else true
+ }
+
+ fun setCursorBlinkingEnabled(cursorBlinkingEnabled: Boolean) {
+ this.mCursorBlinkingEnabled = cursorBlinkingEnabled
+ }
+
+ fun setCursorBlinkState(cursorBlinkState: Boolean) {
+ this.mCursorBlinkState = cursorBlinkState
+ }
+
+ fun isKeypadApplicationMode(): Boolean = isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)
+ fun isCursorKeysApplicationMode(): Boolean = isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS)
+ fun isMouseTrackingActive(): Boolean = isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)
+
+ private fun setDefaultTabStops() {
+ for (i in 0 until mColumns)
+ mTabStop[i] = (i and 7) == 0 && i != 0
+ }
+
+ fun append(buffer: ByteArray, length: Int) {
+ for (i in 0 until length)
+ processByte(buffer[i])
+ }
+
+
+ private fun processByte(byteToProcess: Byte) {
+ if (mUtf8ToFollow > 0) {
+ if ((byteToProcess.toInt() and 0b11000000) == 0b10000000) {
+ // 10xxxxxx, a continuation byte.
+ mUtf8InputBuffer[mUtf8Index++.toInt()] = byteToProcess
+ if (--mUtf8ToFollow == 0.toByte()) {
+ val firstByteMask: Byte = if (mUtf8Index.toInt() == 2) 0b00011111 else (if (mUtf8Index.toInt() == 3) 0b00001111 else 0b00000111)
+ var codePoint = (mUtf8InputBuffer[0].toInt() and firstByteMask.toInt())
+ for (i in 1 until mUtf8Index.toInt())
+ codePoint = ((codePoint shl 6) or (mUtf8InputBuffer[i].toInt() and 0b00111111))
+ if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2)
+ || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) {
+ // Overlong encoding.
+ codePoint = UNICODE_REPLACEMENT_CHAR
+ }
+
+ mUtf8Index = 0
+ mUtf8ToFollow = 0
+
+ if (codePoint >= 0x80 && codePoint <= 0x9F) {
+ // Sequence decoded to a C1 control character which we ignore.
+ } else {
+ when (Character.getType(codePoint).toByte()) {
+ Character.UNASSIGNED, Character.SURROGATE -> codePoint = UNICODE_REPLACEMENT_CHAR
+ }
+ processCodePoint(codePoint)
+ }
+ }
+ } else {
+ // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char:
+ mUtf8Index = 0
+ mUtf8ToFollow = 0
+ emitCodePoint(UNICODE_REPLACEMENT_CHAR)
+ processByte(byteToProcess)
+ }
+ } else {
+ if ((byteToProcess.toInt() and 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character.
+ processCodePoint(byteToProcess.toInt())
+ return
+ } else if ((byteToProcess.toInt() and 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence.
+ mUtf8ToFollow = 1
+ } else if ((byteToProcess.toInt() and 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence.
+ mUtf8ToFollow = 2
+ } else if ((byteToProcess.toInt() and 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence.
+ mUtf8ToFollow = 3
+ } else {
+ // Not a valid UTF-8 sequence start, signal invalid data:
+ processCodePoint(UNICODE_REPLACEMENT_CHAR)
+ return
+ }
+ mUtf8InputBuffer[mUtf8Index++.toInt()] = byteToProcess
+ }
+ }
+
+ fun processCodePoint(b: Int) {
+ // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early.
+ if (mEscapeState == ESC_APC) {
+ doApc(b)
+ return
+ } else if (mEscapeState == ESC_APC_ESCAPE) {
+ doApcEscape(b)
+ return
+ }
+
+ when (b) {
+ 0 -> { } // Null character (NUL, ^@). Do nothing.
+ 7 -> { // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell.
+ if (mEscapeState == ESC_OSC)
+ doOsc(b)
+ else
+ mSession.onBell()
+ }
+ 8 -> { // Backspace (BS, ^H).
+ if (mLeftMargin == mCursorCol) {
+ // Jump to previous line if it was auto-wrapped.
+ val previousRow = mCursorRow - 1
+ if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) {
+ mScreen.clearLineWrap(previousRow)
+ setCursorRowCol(previousRow, mRightMargin - 1)
+ }
+ } else {
+ setCursorCol(mCursorCol - 1)
+ }
+ }
+ 9 -> { // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen
+ mCursorCol = nextTabStop(1)
+ }
+ 10, 11, 12 -> { // Line feed (LF, \n), Vertical tab (VT, \v), Form feed (FF, \f).
+ doLinefeed()
+ }
+ 13 -> { // Carriage return (CR, \r).
+ setCursorCol(mLeftMargin)
+ }
+ 14 -> { // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set.
+ mUseLineDrawingUsesG0 = false
+ }
+ 15 -> { // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set.
+ mUseLineDrawingUsesG0 = true
+ }
+ 24, 26 -> { // CAN, SUB.
+ if (mEscapeState != ESC_NONE) {
+ mEscapeState = ESC_NONE
+ emitCodePoint(127)
+ }
+ }
+ 27 -> { // ESC
+ // Starts an escape sequence unless we're parsing a string
+ if (mEscapeState == ESC_P) {
+ // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator.
+ return
+ } else if (mEscapeState != ESC_OSC) {
+ startEscapeSequence()
+ } else {
+ doOsc(b)
+ }
+ }
+ else -> {
+ mContinueSequence = false
+ when (mEscapeState) {
+ ESC_NONE -> if (b >= 32) emitCodePoint(b)
+ ESC -> doEsc(b)
+ ESC_POUND -> doEscPound(b)
+ ESC_SELECT_LEFT_PAREN -> mUseLineDrawingG0 = (b == '0'.code)
+ ESC_SELECT_RIGHT_PAREN -> mUseLineDrawingG1 = (b == '0'.code)
+ ESC_CSI -> doCsi(b)
+ ESC_CSI_UNSUPPORTED_PARAMETER_BYTE, ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE -> doCsiUnsupportedParameterOrIntermediateByte(b)
+ ESC_CSI_EXCLAMATION -> {
+ if (b == 'p'.code) { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR).
+ reset()
+ } else {
+ unknownSequence(b)
+ }
+ }
+ ESC_CSI_QUESTIONMARK -> doCsiQuestionMark(b)
+ ESC_CSI_BIGGERTHAN -> doCsiBiggerThan(b)
+ ESC_CSI_DOLLAR -> {
+ val originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE)
+ val effectiveTopMargin = if (originMode) mTopMargin else 0
+ val effectiveBottomMargin = if (originMode) mBottomMargin else mRows
+ val effectiveLeftMargin = if (originMode) mLeftMargin else 0
+ val effectiveRightMargin = if (originMode) mRightMargin else mColumns
+ when (b) {
+ 'v'.code -> { // Copy rectangular area (DECCRA)
+ val topSource = minOf(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows)
+ val leftSource = minOf(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns)
+ val bottomSource = minOf(maxOf(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows)
+ val rightSource = minOf(maxOf(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns)
+ val destionationTop = minOf(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows)
+ val destinationLeft = minOf(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns)
+ val heightToCopy = minOf(mRows - destionationTop, bottomSource - topSource)
+ val widthToCopy = minOf(mColumns - destinationLeft, rightSource - leftSource)
+ mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop)
+ }
+ '{'.code, 'x'.code, 'z'.code -> { // DECSERA, DECFRA, DECERA
+ val erase = b != 'x'.code
+ val selective = b == '{'.code
+ val keepVisualAttributes = erase && selective
+ var argIndex = 0
+ val fillChar = if (erase) ' '.code else getArg(argIndex++, -1, true)
+ if ((fillChar in 32..126) || (fillChar in 160..255)) {
+ val top = minOf(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1)
+ val left = minOf(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1)
+ val bottom = minOf(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin)
+ val right = minOf(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin)
+ val style = getStyle()
+ for (row in top - 1 until bottom)
+ for (col in left - 1 until right)
+ if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) and TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, if (keepVisualAttributes) mScreen.getStyleAt(row, col) else style)
+ }
+ }
+ 'r'.code, 't'.code -> { // DECCARA, DECRARA
+ val reverse = b == 't'.code
+ val top = minOf(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin
+ val left = minOf(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin
+ val bottom = minOf(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin
+ val right = minOf(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin
+ if (mArgIndex >= 4) {
+ if (mArgIndex >= mArgs.size) mArgIndex = mArgs.size - 1
+ for (i in 4..mArgIndex) {
+ var bits = 0
+ var setOrClear = true
+ when (getArg(i, 0, false)) {
+ 0 -> {
+ bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE or TextStyle.CHARACTER_ATTRIBUTE_BLINK or TextStyle.CHARACTER_ATTRIBUTE_INVERSE)
+ if (!reverse) setOrClear = false
+ }
+ 1 -> bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD
+ 4 -> bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE
+ 5 -> bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK
+ 7 -> bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE
+ 22 -> { bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; setOrClear = false }
+ 24 -> { bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; setOrClear = false }
+ 25 -> { bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; setOrClear = false }
+ 27 -> { bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; setOrClear = false }
+ }
+ if (!(reverse && !setOrClear)) {
+ mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE),
+ effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right)
+ }
+ }
+ }
+ }
+ else -> unknownSequence(b)
+ }
+ }
+ ESC_CSI_DOUBLE_QUOTE -> {
+ if (b == 'q'.code) {
+ val arg = getArg0(0)
+ if (arg == 0 || arg == 2) {
+ mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_PROTECTED.inv()
+ } else if (arg == 1) {
+ mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_PROTECTED
+ } else {
+ unknownSequence(b)
+ }
+ } else {
+ unknownSequence(b)
+ }
+ }
+ ESC_CSI_SINGLE_QUOTE -> {
+ if (b == '}'.code) { // Insert Ps Column(s) (DECIC)
+ val columnsAfterCursor = mRightMargin - mCursorCol
+ val columnsToInsert = minOf(getArg0(1), columnsAfterCursor)
+ val columnsToMove = columnsAfterCursor - columnsToInsert
+ mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0)
+ blockClear(mCursorCol, 0, columnsToInsert, mRows)
+ } else if (b == '~'.code) { // Delete Ps Column(s) (DECDC)
+ val columnsAfterCursor = mRightMargin - mCursorCol
+ val columnsToDelete = minOf(getArg0(1), columnsAfterCursor)
+ val columnsToMove = columnsAfterCursor - columnsToDelete
+ mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0)
+ } else {
+ unknownSequence(b)
+ }
+ }
+ ESC_PERCENT -> { }
+ ESC_OSC -> doOsc(b)
+ ESC_OSC_ESC -> doOscEsc(b)
+ ESC_P -> doDeviceControl(b)
+ ESC_CSI_QUESTIONMARK_ARG_DOLLAR -> {
+ if (b == 'p'.code) {
+ val mode = getArg0(0)
+ val value: Int
+ if (mode == 47 || mode == 1047 || mode == 1049) {
+ value = if (mScreen === mAltBuffer) 1 else 2
+ } else {
+ val internalBit = mapDecSetBitToInternalBit(mode)
+ if (internalBit != -1) {
+ value = if (isDecsetInternalBitSet(internalBit)) 1 else 2
+ } else {
+ Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=$mode")
+ value = 0
+ }
+ }
+ mSession.write(String.format(Locale.US, "\u001b[?%d;%d\$y", mode, value))
+ } else {
+ unknownSequence(b)
+ }
+ }
+ ESC_CSI_ARGS_SPACE -> {
+ val arg = getArg0(0)
+ when (b) {
+ 'q'.code -> {
+ when (arg) {
+ 0, 1, 2 -> mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK
+ 3, 4 -> mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE
+ 5, 6 -> mCursorStyle = TERMINAL_CURSOR_STYLE_BAR
+ }
+ }
+ 't'.code, 'u'.code -> { } // Set margin-bell volume - ignore.
+ else -> unknownSequence(b)
+ }
+ }
+ ESC_CSI_ARGS_ASTERIX -> {
+ val attributeChangeExtent = getArg0(0)
+ if (b == 'x'.code && (attributeChangeExtent in 0..2)) {
+ setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2)
+ } else {
+ unknownSequence(b)
+ }
+ }
+ else -> unknownSequence(b)
+ }
+ if (!mContinueSequence) mEscapeState = ESC_NONE
+ }
+ }
+ }
+
+ /** When in [ESC_P] ("device control") sequence. */
+ private fun doDeviceControl(b: Int) {
+ when (b) {
+ '\\'.code -> {
+ val dcs = mOSCOrDeviceControlArgs.toString()
+ if (dcs.startsWith("\$q")) {
+ if (dcs == "\$q\"p") {
+ val csiString = "64;1\"p"
+ mSession.write("\u001bP1\$r$csiString\u001b\\")
+ } else {
+ finishSequenceAndLogError("Unrecognized DECRQSS string: '$dcs'")
+ }
+ } else if (dcs.startsWith("+q")) {
+ for (part in dcs.substring(2).split(";")) {
+ if (part.length % 2 == 0) {
+ val transBuffer = StringBuilder()
+ var c: Char
+ var i = 0
+ while (i < part.length) {
+ try {
+ c = java.lang.Long.decode("0x" + part[i] + "" + part[i + 1]).toLong().toInt().toChar()
+ } catch (e: NumberFormatException) {
+ Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"$part\"", e)
+ i += 2
+ continue
+ }
+ transBuffer.append(c)
+ i += 2
+ }
+
+ val trans = transBuffer.toString()
+ val responseValue: String? = when (trans) {
+ "Co", "colors" -> "256"
+ "TN", "name" -> "xterm"
+ else -> KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS),
+ isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD))
+ }
+ if (responseValue == null) {
+ when (trans) {
+ "%1", "&8" -> { } // Help/Undo key - ignore
+ else -> Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '$trans'")
+ }
+ mSession.write("\u001bP0+r$part\u001b\\")
+ } else {
+ val hexEncoded = StringBuilder()
+ for (j in responseValue.indices) {
+ hexEncoded.append(String.format("%02X", responseValue[j].code))
+ }
+ mSession.write("\u001bP1+r$part=$hexEncoded\u001b\\")
+ }
+ } else {
+ Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: $part")
+ }
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES)
+ Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: $dcs")
+ }
+ finishSequence()
+ }
+ else -> {
+ if (mOSCOrDeviceControlArgs.length > MAX_OSC_STRING_LENGTH) {
+ mOSCOrDeviceControlArgs.setLength(0)
+ finishSequence()
+ } else {
+ mOSCOrDeviceControlArgs.appendCodePoint(b)
+ continueSequence(mEscapeState)
+ }
+ }
+ }
+ }
+
+ /** When in [ESC_APC] (APC, Application Program Command) sequence. */
+ private fun doApc(b: Int) {
+ if (b == 27) {
+ continueSequence(ESC_APC_ESCAPE)
+ }
+ // Eat APC sequences silently for now.
+ }
+
+ /** When in [ESC_APC] (APC, Application Program Command) sequence. */
+ private fun doApcEscape(b: Int) {
+ if (b == '\\'.code) {
+ finishSequence()
+ } else {
+ continueSequence(ESC_APC)
+ }
+ }
+
+ private fun nextTabStop(numTabs: Int): Int {
+ var tabs = numTabs
+ for (i in mCursorCol + 1 until mColumns)
+ if (mTabStop[i] && --tabs == 0) return minOf(i, mRightMargin)
+ return mRightMargin - 1
+ }
+
+ private fun doCsiUnsupportedParameterOrIntermediateByte(b: Int) {
+ if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) {
+ continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE)
+ } else if (b >= 0x20 && b <= 0x2F) {
+ continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE)
+ } else if (b >= 0x40 && b <= 0x7E) {
+ finishSequence()
+ } else {
+ unknownSequence(b)
+ }
+ }
+
+ /** Process byte while in the [ESC_CSI_QUESTIONMARK] escape state. */
+ private fun doCsiQuestionMark(b: Int) {
+ when (b) {
+ 'J'.code, 'K'.code -> { // DECSED, DECSEL
+ mAboutToAutoWrap = false
+ val fillChar = ' '.code
+ var startCol = -1
+ var startRow = -1
+ var endCol = -1
+ var endRow = -1
+ val justRow = (b == 'K'.code)
+ when (getArg0(0)) {
+ 0 -> {
+ startCol = mCursorCol; startRow = mCursorRow
+ endCol = mColumns; endRow = if (justRow) (mCursorRow + 1) else mRows
+ }
+ 1 -> {
+ startCol = 0; startRow = if (justRow) mCursorRow else 0
+ endCol = mCursorCol + 1; endRow = mCursorRow + 1
+ }
+ 2 -> {
+ startCol = 0; startRow = if (justRow) mCursorRow else 0
+ endCol = mColumns; endRow = if (justRow) (mCursorRow + 1) else mRows
+ }
+ else -> unknownSequence(b)
+ }
+ val style = getStyle()
+ for (row in startRow until endRow) {
+ for (col in startCol until endCol) {
+ if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) and TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, style)
+ }
+ }
+ }
+ 'h'.code, 'l'.code -> {
+ if (mArgIndex >= mArgs.size) mArgIndex = mArgs.size - 1
+ for (i in 0..mArgIndex)
+ doDecSetOrReset(b == 'h'.code, mArgs[i])
+ }
+ 'n'.code -> { // Device Status Report (DSR, DEC-specific)
+ when (getArg0(-1)) {
+ 6 -> mSession.write(String.format(Locale.US, "\u001b[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1))
+ else -> finishSequence()
+ }
+ }
+ 'r'.code, 's'.code -> {
+ if (mArgIndex >= mArgs.size) mArgIndex = mArgs.size - 1
+ for (i in 0..mArgIndex) {
+ val externalBit = mArgs[i]
+ val internalBit = mapDecSetBitToInternalBit(externalBit)
+ if (internalBit == -1) {
+ Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=$externalBit")
+ } else {
+ if (b == 's'.code) {
+ mSavedDecSetFlags = mSavedDecSetFlags or internalBit
+ } else {
+ doDecSetOrReset((mSavedDecSetFlags and internalBit) != 0, externalBit)
+ }
+ }
+ }
+ }
+ '$'.code -> {
+ continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR)
+ return
+ }
+ else -> parseArg(b)
+ }
+ }
+
+ fun doDecSetOrReset(setting: Boolean, externalBit: Int) {
+ val internalBit = mapDecSetBitToInternalBit(externalBit)
+ if (internalBit != -1) {
+ setDecsetinternalBit(internalBit, setting)
+ }
+ when (externalBit) {
+ 1 -> { } // Application Cursor Keys (DECCKM).
+ 3 -> {
+ mLeftMargin = 0; mTopMargin = 0
+ mBottomMargin = mRows
+ mRightMargin = mColumns
+ setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false)
+ blockClear(0, 0, mColumns, mRows)
+ setCursorRowCol(0, 0)
+ }
+ 4 -> { } // DECSCLM-Scrolling Mode. Ignore.
+ 5 -> { } // Reverse video. No action.
+ 6 -> { if (setting) setCursorPosition(0, 0) } // DECOM
+ 7, 8, 9, 12 -> { } // Various - ignore
+ 25 -> { mClient?.onTerminalCursorStateChange(setting) }
+ 40, 45, 66 -> { } // Ignore
+ 69 -> { if (!setting) { mLeftMargin = 0; mRightMargin = mColumns } }
+ 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015, 1034 -> { }
+ 1048 -> { if (setting) saveCursor() else restoreCursor() }
+ 47, 1047, 1049 -> {
+ val newScreen = if (setting) mAltBuffer else mMainBuffer
+ if (newScreen !== mScreen) {
+ val resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows)
+ if (setting) saveCursor()
+ mScreen = newScreen
+ if (!setting) {
+ val col = mSavedStateMain.mSavedCursorCol
+ val row = mSavedStateMain.mSavedCursorRow
+ restoreCursor()
+ if (resized) {
+ mCursorCol = col
+ mCursorRow = row
+ }
+ }
+ if (resized) resizeScreen()
+ if (newScreen === mAltBuffer)
+ newScreen.blockSet(0, 0, mColumns, mRows, ' '.code, getStyle())
+ }
+ }
+ 2004 -> { } // Bracketed paste mode - setting bit is enough.
+ else -> unknownParameter(externalBit)
+ }
+ }
+
+ private fun doCsiBiggerThan(b: Int) {
+ when (b) {
+ 'c'.code -> { // Secondary Device Attributes (DA2)
+ mSession.write("\u001b[>41;320;0c")
+ }
+ 'm'.code -> {
+ Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: ${getArg0(-1)} to ${getArg1(-1)}")
+ }
+ else -> parseArg(b)
+ }
+ }
+
+ private fun startEscapeSequence() {
+ mEscapeState = ESC
+ mArgIndex = 0
+ Arrays.fill(mArgs, -1)
+ mArgsSubParamsBitSet = 0
+ }
+
+ private fun doLinefeed() {
+ val belowScrollingRegion = mCursorRow >= mBottomMargin
+ var newCursorRow = mCursorRow + 1
+ if (belowScrollingRegion) {
+ if (mCursorRow != mRows - 1) {
+ setCursorRow(newCursorRow)
+ }
+ } else {
+ if (newCursorRow == mBottomMargin) {
+ scrollDownOneLine()
+ newCursorRow = mBottomMargin - 1
+ }
+ setCursorRow(newCursorRow)
+ }
+ }
+
+ private fun continueSequence(state: Int) {
+ mEscapeState = state
+ mContinueSequence = true
+ }
+
+ private fun doEscPound(b: Int) {
+ when (b) {
+ '8'.code -> mScreen.blockSet(0, 0, mColumns, mRows, 'E'.code, getStyle())
+ else -> unknownSequence(b)
+ }
+ }
+
+ /** Encountering a character in the [ESC] state. */
+ private fun doEsc(b: Int) {
+ when (b) {
+ '#'.code -> continueSequence(ESC_POUND)
+ '('.code -> continueSequence(ESC_SELECT_LEFT_PAREN)
+ ')'.code -> continueSequence(ESC_SELECT_RIGHT_PAREN)
+ '6'.code -> { // Back index (DECBI)
+ if (mCursorCol > mLeftMargin) {
+ mCursorCol--
+ } else {
+ val rows = mBottomMargin - mTopMargin
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin)
+ mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' '.code, TextStyle.encode(mForeColor, mBackColor, 0))
+ }
+ }
+ '7'.code -> saveCursor() // DECSC
+ '8'.code -> restoreCursor() // DECRC
+ '9'.code -> { // Forward Index (DECFI)
+ if (mCursorCol < mRightMargin - 1) {
+ mCursorCol++
+ } else {
+ val rows = mBottomMargin - mTopMargin
+ mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin)
+ mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' '.code, TextStyle.encode(mForeColor, mBackColor, 0))
+ }
+ }
+ 'c'.code -> { // RIS
+ reset()
+ mMainBuffer.clearTranscript()
+ blockClear(0, 0, mColumns, mRows)
+ setCursorPosition(0, 0)
+ }
+ 'D'.code -> doLinefeed() // INDEX
+ 'E'.code -> { // Next line (NEL)
+ setCursorCol(if (isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE)) mLeftMargin else 0)
+ doLinefeed()
+ }
+ 'F'.code -> setCursorRowCol(0, mBottomMargin - 1) // Cursor to lower-left corner
+ 'H'.code -> mTabStop[mCursorCol] = true // Tab set
+ 'M'.code -> { // Reverse index (RI)
+ if (mCursorRow <= mTopMargin) {
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1)
+ blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin)
+ } else {
+ mCursorRow--
+ }
+ }
+ 'N'.code, '0'.code -> { } // SS2, SS3 - ignore
+ 'P'.code -> { // Device control string
+ mOSCOrDeviceControlArgs.setLength(0)
+ continueSequence(ESC_P)
+ }
+ '['.code -> continueSequence(ESC_CSI)
+ '='.code -> setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true) // DECKPAM
+ ']'.code -> { // OSC
+ mOSCOrDeviceControlArgs.setLength(0)
+ continueSequence(ESC_OSC)
+ }
+ '>'.code -> setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false) // DECKPNM
+ '_'.code -> continueSequence(ESC_APC) // APC
+ else -> unknownSequence(b)
+ }
+ }
+
+ /** DECSC save cursor. */
+ private fun saveCursor() {
+ val state = if (mScreen === mMainBuffer) mSavedStateMain else mSavedStateAlt
+ state.mSavedCursorRow = mCursorRow
+ state.mSavedCursorCol = mCursorCol
+ state.mSavedEffect = mEffect
+ state.mSavedForeColor = mForeColor
+ state.mSavedBackColor = mBackColor
+ state.mSavedDecFlags = mCurrentDecSetFlags
+ state.mUseLineDrawingG0 = mUseLineDrawingG0
+ state.mUseLineDrawingG1 = mUseLineDrawingG1
+ state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0
+ }
+
+ /** DECRS restore cursor. */
+ private fun restoreCursor() {
+ val state = if (mScreen === mMainBuffer) mSavedStateMain else mSavedStateAlt
+ setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol)
+ mEffect = state.mSavedEffect
+ mForeColor = state.mSavedForeColor
+ mBackColor = state.mSavedBackColor
+ val mask = (DECSET_BIT_AUTOWRAP or DECSET_BIT_ORIGIN_MODE)
+ mCurrentDecSetFlags = (mCurrentDecSetFlags and mask.inv()) or (state.mSavedDecFlags and mask)
+ mUseLineDrawingG0 = state.mUseLineDrawingG0
+ mUseLineDrawingG1 = state.mUseLineDrawingG1
+ mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0
+ }
+
+ /** Following a CSI - Control Sequence Introducer, "\033[". [ESC_CSI]. */
+ private fun doCsi(b: Int) {
+ when (b) {
+ '!'.code -> continueSequence(ESC_CSI_EXCLAMATION)
+ '"'.code -> continueSequence(ESC_CSI_DOUBLE_QUOTE)
+ '\''.code -> continueSequence(ESC_CSI_SINGLE_QUOTE)
+ '$'.code -> continueSequence(ESC_CSI_DOLLAR)
+ '*'.code -> continueSequence(ESC_CSI_ARGS_ASTERIX)
+ '@'.code -> { // Insert ${n} space characters (ICH)
+ mAboutToAutoWrap = false
+ val columnsAfterCursor = mColumns - mCursorCol
+ val spacesToInsert = minOf(getArg0(1), columnsAfterCursor)
+ val charsToMove = columnsAfterCursor - spacesToInsert
+ mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow)
+ blockClear(mCursorCol, mCursorRow, spacesToInsert)
+ }
+ 'A'.code -> setCursorRow(maxOf(0, mCursorRow - getArg0(1))) // CUU
+ 'B'.code -> setCursorRow(minOf(mRows - 1, mCursorRow + getArg0(1))) // CUD
+ 'C'.code, 'a'.code -> setCursorCol(minOf(mRightMargin - 1, mCursorCol + getArg0(1))) // CUF, HPR
+ 'D'.code -> setCursorCol(maxOf(mLeftMargin, mCursorCol - getArg0(1))) // CUB
+ 'E'.code -> setCursorPosition(0, mCursorRow + getArg0(1)) // CNL
+ 'F'.code -> setCursorPosition(0, mCursorRow - getArg0(1)) // CPL
+ 'G'.code -> setCursorCol(minOf(maxOf(1, getArg0(1)), mColumns) - 1) // CHA
+ 'H'.code, 'f'.code -> setCursorPosition(getArg1(1) - 1, getArg0(1) - 1) // CUP, HVP
+ 'I'.code -> setCursorCol(nextTabStop(getArg0(1))) // CHT
+ 'J'.code -> { // ED - Erase in Display
+ when (getArg0(0)) {
+ 0 -> {
+ blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol)
+ blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1))
+ }
+ 1 -> {
+ blockClear(0, 0, mColumns, mCursorRow)
+ blockClear(0, mCursorRow, mCursorCol + 1)
+ }
+ 2 -> blockClear(0, 0, mColumns, mRows)
+ 3 -> mMainBuffer.clearTranscript()
+ else -> { unknownSequence(b); return }
+ }
+ mAboutToAutoWrap = false
+ }
+ 'K'.code -> { // EL - Erase in line
+ when (getArg0(0)) {
+ 0 -> blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol)
+ 1 -> blockClear(0, mCursorRow, mCursorCol + 1)
+ 2 -> blockClear(0, mCursorRow, mColumns)
+ else -> { unknownSequence(b); return }
+ }
+ mAboutToAutoWrap = false
+ }
+ 'L'.code -> { // IL - insert lines
+ val linesAfterCursor = mBottomMargin - mCursorRow
+ val linesToInsert = minOf(getArg0(1), linesAfterCursor)
+ val linesToMove = linesAfterCursor - linesToInsert
+ mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert)
+ blockClear(0, mCursorRow, mColumns, linesToInsert)
+ }
+ 'M'.code -> { // DL - delete lines
+ mAboutToAutoWrap = false
+ val linesAfterCursor = mBottomMargin - mCursorRow
+ val linesToDelete = minOf(getArg0(1), linesAfterCursor)
+ val linesToMove = linesAfterCursor - linesToDelete
+ mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow)
+ blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete)
+ }
+ 'P'.code -> { // DCH - delete characters
+ mAboutToAutoWrap = false
+ val cellsAfterCursor = mColumns - mCursorCol
+ val cellsToDelete = minOf(getArg0(1), cellsAfterCursor)
+ val cellsToMove = cellsAfterCursor - cellsToDelete
+ mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow)
+ blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete)
+ }
+ 'S'.code -> { // SU - scroll up
+ val linesToScroll = getArg0(1)
+ for (i in 0 until linesToScroll) scrollDownOneLine()
+ }
+ 'T'.code -> {
+ if (mArgIndex == 0) {
+ val linesToScrollArg = getArg0(1)
+ val linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin
+ val linesToScroll = minOf(linesBetweenTopAndBottomMargins, linesToScrollArg)
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll)
+ blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll)
+ } else {
+ unimplementedSequence(b)
+ }
+ }
+ 'X'.code -> { // ECH - Erase characters
+ mAboutToAutoWrap = false
+ mScreen.blockSet(mCursorCol, mCursorRow, minOf(getArg0(1), mColumns - mCursorCol), 1, ' '.code, getStyle())
+ }
+ 'Z'.code -> { // CBT - Cursor Backward Tabulation
+ var numberOfTabs = getArg0(1)
+ var newCol = mLeftMargin
+ for (i in mCursorCol - 1 downTo 0) {
+ if (mTabStop[i]) {
+ if (--numberOfTabs == 0) {
+ newCol = maxOf(i, mLeftMargin)
+ break
+ }
+ }
+ }
+ mCursorCol = newCol
+ }
+ '?'.code -> continueSequence(ESC_CSI_QUESTIONMARK)
+ '>'.code -> continueSequence(ESC_CSI_BIGGERTHAN)
+ '<'.code, '='.code -> continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE)
+ '`'.code -> setCursorColRespectingOriginMode(getArg0(1) - 1) // HPA
+ 'b'.code -> { // REP - Repeat
+ if (mLastEmittedCodePoint != -1) {
+ val numRepeat = getArg0(1)
+ for (i in 0 until numRepeat) emitCodePoint(mLastEmittedCodePoint)
+ }
+ }
+ 'c'.code -> { // DA1 - Primary Device Attributes
+ if (getArg0(0) == 0) mSession.write("\u001b[?64;1;2;6;9;15;18;21;22c")
+ }
+ 'd'.code -> setCursorRow(minOf(maxOf(1, getArg0(1)), mRows) - 1) // VPA
+ 'e'.code -> setCursorPosition(mCursorCol, mCursorRow + getArg0(1)) // VPR
+ 'g'.code -> { // Clear tab stop
+ when (getArg0(0)) {
+ 0 -> mTabStop[mCursorCol] = false
+ 3 -> for (i in 0 until mColumns) mTabStop[i] = false
+ }
+ }
+ 'h'.code -> doSetMode(true)
+ 'l'.code -> doSetMode(false)
+ 'm'.code -> selectGraphicRendition() // SGR
+ 'n'.code -> { // DSR - Device Status Report
+ when (getArg0(0)) {
+ 5 -> {
+ val dsr = byteArrayOf(27.toByte(), '['.code.toByte(), '0'.code.toByte(), 'n'.code.toByte())
+ mSession.write(dsr, 0, dsr.size)
+ }
+ 6 -> mSession.write(String.format(Locale.US, "\u001b[%d;%dR", mCursorRow + 1, mCursorCol + 1))
+ }
+ }
+ 'r'.code -> { // DECSTBM - set top and bottom margins
+ mTopMargin = maxOf(0, minOf(getArg0(1) - 1, mRows - 2))
+ mBottomMargin = maxOf(mTopMargin + 2, minOf(getArg1(mRows), mRows))
+ setCursorPosition(0, 0)
+ }
+ 's'.code -> {
+ if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) {
+ mLeftMargin = minOf(getArg0(1) - 1, mColumns - 2)
+ mRightMargin = maxOf(mLeftMargin + 1, minOf(getArg1(mColumns), mColumns))
+ setCursorPosition(0, 0)
+ } else {
+ saveCursor()
+ }
+ }
+ 't'.code -> { // Window manipulation
+ when (getArg0(0)) {
+ 11 -> mSession.write("\u001b[1t")
+ 13 -> mSession.write("\u001b[3;0;0t")
+ 14 -> mSession.write(String.format(Locale.US, "\u001b[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels))
+ 16 -> mSession.write(String.format(Locale.US, "\u001b[6;%d;%dt", mCellHeightPixels, mCellWidthPixels))
+ 18 -> mSession.write(String.format(Locale.US, "\u001b[8;%d;%dt", mRows, mColumns))
+ 19 -> mSession.write(String.format(Locale.US, "\u001b[9;%d;%dt", mRows, mColumns))
+ 20 -> mSession.write("\u001b]LIconLabel\u001b\\")
+ 21 -> mSession.write("\u001b]l\u001b\\")
+ 22 -> {
+ mTitleStack.push(mTitle)
+ if (mTitleStack.size > 20) mTitleStack.removeAt(0)
+ }
+ 23 -> if (mTitleStack.isNotEmpty()) setTitle(mTitleStack.pop())
+ }
+ }
+ 'u'.code -> restoreCursor()
+ ' '.code -> continueSequence(ESC_CSI_ARGS_SPACE)
+ else -> parseArg(b)
+ }
+ }
+
+ /** Select Graphic Rendition (SGR). */
+ private fun selectGraphicRendition() {
+ if (mArgIndex >= mArgs.size) mArgIndex = mArgs.size - 1
+ var i = 0
+ while (i <= mArgIndex) {
+ if ((mArgsSubParamsBitSet and (1 shl i)) != 0) {
+ i++
+ continue
+ }
+
+ var code = getArg(i, 0, false)
+ if (code < 0) {
+ code = if (mArgIndex > 0) { i++; continue } else 0
+ }
+ when {
+ code == 0 -> {
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND
+ mEffect = 0
+ }
+ code == 1 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_BOLD
+ code == 2 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_DIM
+ code == 3 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_ITALIC
+ code == 4 -> {
+ if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet and (1 shl (i + 1))) != 0)) {
+ i++
+ if (mArgs[i] == 0) {
+ mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE.inv()
+ } else {
+ mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE
+ }
+ } else {
+ mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE
+ }
+ }
+ code == 5 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_BLINK
+ code == 7 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_INVERSE
+ code == 8 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE
+ code == 9 -> mEffect = mEffect or TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH
+ code == 10 || code == 11 -> { } // Exit/enter alt charset (TERM=linux) - ignore.
+ code == 22 -> mEffect = mEffect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_DIM).inv()
+ code == 23 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_ITALIC.inv()
+ code == 24 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE.inv()
+ code == 25 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_BLINK.inv()
+ code == 27 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE.inv()
+ code == 28 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE.inv()
+ code == 29 -> mEffect = mEffect and TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH.inv()
+ code in 30..37 -> mForeColor = code - 30
+ code == 38 || code == 48 || code == 58 -> {
+ if (i + 2 > mArgIndex) { i++; continue }
+ val firstArg = mArgs[i + 1]
+ if (firstArg == 2) {
+ if (i + 4 > mArgIndex) {
+ Logger.logWarn(mClient, LOG_TAG, "Too few CSI$code;2 RGB arguments")
+ } else {
+ val red = getArg(i + 2, 0, false)
+ val green = getArg(i + 3, 0, false)
+ val blue = getArg(i + 4, 0, false)
+ if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) {
+ finishSequenceAndLogError("Invalid RGB: $red,$green,$blue")
+ } else {
+ val argbColor = 0xff_00_00_00.toInt() or (red shl 16) or (green shl 8) or blue
+ when (code) {
+ 38 -> mForeColor = argbColor
+ 48 -> mBackColor = argbColor
+ 58 -> mUnderlineColor = argbColor
+ }
+ }
+ i += 4
+ }
+ } else if (firstArg == 5) {
+ val color = getArg(i + 2, 0, false)
+ i += 2
+ if (color in 0 until TextStyle.NUM_INDEXED_COLORS) {
+ when (code) {
+ 38 -> mForeColor = color
+ 48 -> mBackColor = color
+ 58 -> mUnderlineColor = color
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: $color")
+ }
+ } else {
+ finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: $firstArg")
+ }
+ }
+ code == 39 -> mForeColor = TextStyle.COLOR_INDEX_FOREGROUND
+ code in 40..47 -> mBackColor = code - 40
+ code == 49 -> mBackColor = TextStyle.COLOR_INDEX_BACKGROUND
+ code == 59 -> mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND
+ code in 90..97 -> mForeColor = code - 90 + 8
+ code in 100..107 -> mBackColor = code - 100 + 8
+ else -> {
+ if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code))
+ }
+ }
+ i++
+ }
+ }
+
+ private fun doOsc(b: Int) {
+ when (b) {
+ 7 -> doOscSetTextParameters("\u0007") // Bell
+ 27 -> continueSequence(ESC_OSC_ESC) // Escape
+ else -> collectOSCArgs(b)
+ }
+ }
+
+ private fun doOscEsc(b: Int) {
+ when (b) {
+ '\\'.code -> doOscSetTextParameters("\u001b\\")
+ else -> {
+ collectOSCArgs(27)
+ collectOSCArgs(b)
+ continueSequence(ESC_OSC)
+ }
+ }
+ }
+
+ /** An Operating System Controls (OSC) Set Text Parameters. */
+ private fun doOscSetTextParameters(bellOrStringTerminator: String) {
+ var value = -1
+ var textParameter = ""
+ for (mOSCArgTokenizerIndex in 0 until mOSCOrDeviceControlArgs.length) {
+ val bChar = mOSCOrDeviceControlArgs[mOSCArgTokenizerIndex]
+ if (bChar == ';') {
+ textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1)
+ break
+ } else if (bChar in '0'..'9') {
+ value = (if (value < 0) 0 else value * 10) + (bChar - '0')
+ } else {
+ unknownSequence(bChar.code)
+ return
+ }
+ }
+
+ when (value) {
+ 0, 1, 2 -> setTitle(textParameter)
+ 4 -> {
+ var colorIndex = -1
+ var parsingPairStart = -1
+ var i = 0
+ while (true) {
+ val endOfInput = i == textParameter.length
+ val bChar = if (endOfInput) ';' else textParameter[i]
+ if (bChar == ';') {
+ if (parsingPairStart < 0) {
+ parsingPairStart = i + 1
+ } else {
+ if (colorIndex < 0 || colorIndex > 255) {
+ unknownSequence(bChar.code)
+ return
+ } else {
+ mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i))
+ mSession.onColorsChanged()
+ colorIndex = -1
+ parsingPairStart = -1
+ }
+ }
+ } else if (parsingPairStart >= 0) {
+ // We have passed a color index and are now going through color spec.
+ } else if (parsingPairStart < 0 && (bChar in '0'..'9')) {
+ colorIndex = (if (colorIndex < 0) 0 else colorIndex * 10) + (bChar - '0')
+ } else {
+ unknownSequence(bChar.code)
+ return
+ }
+ if (endOfInput) break
+ i++
+ }
+ }
+ 10, 11, 12 -> {
+ var specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10)
+ var lastSemiIndex = 0
+ var charIndex = 0
+ while (true) {
+ val endOfInput = charIndex == textParameter.length
+ if (endOfInput || textParameter[charIndex] == ';') {
+ try {
+ val colorSpec = textParameter.substring(lastSemiIndex, charIndex)
+ if (colorSpec == "?") {
+ val rgb = mColors.mCurrentColors[specialIndex]
+ val r = (65535 * ((rgb and 0x00FF0000) shr 16)) / 255
+ val g = (65535 * ((rgb and 0x0000FF00) shr 8)) / 255
+ val bVal = (65535 * (rgb and 0x000000FF)) / 255
+ mSession.write("\u001b]$value;rgb:${String.format(Locale.US, "%04x", r)}/${String.format(Locale.US, "%04x", g)}/${String.format(Locale.US, "%04x", bVal)}$bellOrStringTerminator")
+ } else {
+ mColors.tryParseColor(specialIndex, colorSpec)
+ mSession.onColorsChanged()
+ }
+ specialIndex++
+ if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length)
+ break
+ lastSemiIndex = charIndex
+ } catch (e: NumberFormatException) {
+ // Ignore.
+ }
+ }
+ if (!endOfInput) charIndex++
+ }
+ }
+ 52 -> { // Manipulate Selection Data
+ val startIndex = textParameter.indexOf(";") + 1
+ try {
+ val clipboardText = String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8)
+ mSession.onCopyTextToClipboard(clipboardText)
+ } catch (e: Exception) {
+ Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '$textParameter'")
+ }
+ }
+ 104 -> {
+ if (textParameter.isEmpty()) {
+ mColors.reset()
+ mSession.onColorsChanged()
+ } else {
+ var lastIndex = 0
+ var charIndex = 0
+ while (true) {
+ val endOfInput = charIndex == textParameter.length
+ if (endOfInput || textParameter[charIndex] == ';') {
+ try {
+ val colorToReset = textParameter.substring(lastIndex, charIndex).toInt()
+ mColors.reset(colorToReset)
+ mSession.onColorsChanged()
+ if (endOfInput) break
+ charIndex++
+ lastIndex = charIndex
+ } catch (e: NumberFormatException) {
+ // Ignore.
+ }
+ }
+ if (!endOfInput) charIndex++
+ }
+ }
+ }
+ 110, 111, 112 -> {
+ mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110))
+ mSession.onColorsChanged()
+ }
+ 119 -> { } // Reset highlight color - ignore
+ else -> unknownParameter(value)
+ }
+ finishSequence()
+ }
+
+ private fun blockClear(sx: Int, sy: Int, w: Int) {
+ blockClear(sx, sy, w, 1)
+ }
+
+ private fun blockClear(sx: Int, sy: Int, w: Int, h: Int) {
+ mScreen.blockSet(sx, sy, w, h, ' '.code, getStyle())
+ }
+
+ private fun getStyle(): Long {
+ return TextStyle.encode(mForeColor, mBackColor, mEffect)
+ }
+
+ /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */
+ private fun doSetMode(newValue: Boolean) {
+ val modeBit = getArg0(0)
+ when (modeBit) {
+ 4 -> mInsertMode = newValue // IRM
+ 20 -> unknownParameter(modeBit) // LNM
+ 34 -> { } // Normal cursor visibility - ignore
+ else -> unknownParameter(modeBit)
+ }
+ }
+
+ /** NOTE: The parameters of this function respect the [DECSET_BIT_ORIGIN_MODE]. */
+ private fun setCursorPosition(x: Int, y: Int) {
+ val originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE)
+ val effectiveTopMargin = if (originMode) mTopMargin else 0
+ val effectiveBottomMargin = if (originMode) mBottomMargin else mRows
+ val effectiveLeftMargin = if (originMode) mLeftMargin else 0
+ val effectiveRightMargin = if (originMode) mRightMargin else mColumns
+ val newRow = maxOf(effectiveTopMargin, minOf(effectiveTopMargin + y, effectiveBottomMargin - 1))
+ val newCol = maxOf(effectiveLeftMargin, minOf(effectiveLeftMargin + x, effectiveRightMargin - 1))
+ setCursorRowCol(newRow, newCol)
+ }
+
+ private fun scrollDownOneLine() {
+ mScrollCounter++
+ val currentStyle = getStyle()
+ if (mLeftMargin != 0 || mRightMargin != mColumns) {
+ mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin)
+ mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' '.code, currentStyle)
+ } else {
+ mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle)
+ }
+ }
+
+ /**
+ * Send a Unicode code point to the screen.
+ *
+ * @param codePoint The code point of the character to display
+ */
+ private fun emitCodePoint(codePoint: Int) {
+ var cp = codePoint
+ mLastEmittedCodePoint = cp
+ if (if (mUseLineDrawingUsesG0) mUseLineDrawingG0 else mUseLineDrawingG1) {
+ // http://www.vt100.net/docs/vt102-ug/table5-15.html.
+ cp = when (cp) {
+ '_'.code -> ' '.code // Blank.
+ '`'.code -> '◆'.code // Diamond.
+ '0'.code -> '█'.code // Solid block;
+ 'a'.code -> '▒'.code // Checker board.
+ 'b'.code -> '␉'.code // Horizontal tab.
+ 'c'.code -> '␌'.code // Form feed.
+ 'd'.code -> '\r'.code // Carriage return.
+ 'e'.code -> '␊'.code // Linefeed.
+ 'f'.code -> '°'.code // Degree.
+ 'g'.code -> '±'.code // Plus-minus.
+ 'h'.code -> '\n'.code // Newline.
+ 'i'.code -> '␋'.code // Vertical tab.
+ 'j'.code -> '┘'.code // Lower right corner.
+ 'k'.code -> '┐'.code // Upper right corner.
+ 'l'.code -> '┌'.code // Upper left corner.
+ 'm'.code -> '└'.code // Left left corner.
+ 'n'.code -> '┼'.code // Crossing lines.
+ 'o'.code -> '⎺'.code // Horizontal line - scan 1.
+ 'p'.code -> '⎻'.code // Horizontal line - scan 3.
+ 'q'.code -> '─'.code // Horizontal line - scan 5.
+ 'r'.code -> '⎼'.code // Horizontal line - scan 7.
+ 's'.code -> '⎽'.code // Horizontal line - scan 9.
+ 't'.code -> '├'.code // T facing rightwards.
+ 'u'.code -> '┤'.code // T facing leftwards.
+ 'v'.code -> '┴'.code // T facing upwards.
+ 'w'.code -> '┬'.code // T facing downwards.
+ 'x'.code -> '│'.code // Vertical line.
+ 'y'.code -> '≤'.code // Less than or equal to.
+ 'z'.code -> '≥'.code // Greater than or equal to.
+ '{'.code -> 'π'.code // Pi.
+ '|'.code -> '≠'.code // Not equal to.
+ '}'.code -> '£'.code // UK pound.
+ '~'.code -> '·'.code // Centered dot.
+ else -> cp
+ }
+ }
+
+ val autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP)
+ val displayWidth = WcWidth.width(cp)
+ val cursorInLastColumn = mCursorCol == mRightMargin - 1
+
+ if (autoWrap) {
+ if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) {
+ mScreen.setLineWrap(mCursorRow)
+ mCursorCol = mLeftMargin
+ if (mCursorRow + 1 < mBottomMargin) {
+ mCursorRow++
+ } else {
+ scrollDownOneLine()
+ }
+ }
+ } else if (cursorInLastColumn && displayWidth == 2) {
+ // The behaviour when a wide character is output with cursor in the last column when
+ // autowrap is disabled is not obvious - it's ignored here.
+ return
+ }
+
+ if (mInsertMode && displayWidth > 0) {
+ // Move character to right one space.
+ val destCol = mCursorCol + displayWidth
+ if (destCol < mRightMargin)
+ mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow)
+ }
+
+ val offsetDueToCombiningChar = if (displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) 1 else 0
+ var column = mCursorCol - offsetDueToCombiningChar
+
+ // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported
+ if (column < 0) column = 0
+ mScreen.setChar(column, mCursorRow, cp, getStyle())
+
+ if (autoWrap && displayWidth > 0)
+ mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth)
+
+ mCursorCol = minOf(mCursorCol + displayWidth, mRightMargin - 1)
+ }
+
+ private fun parseArg(b: Int) {
+ if (b in '0'.code..'9'.code) {
+ if (mArgIndex < mArgs.size) {
+ val oldValue = mArgs[mArgIndex]
+ val thisDigit = b - '0'.code
+ var value: Int
+ if (oldValue >= 0) {
+ value = oldValue * 10 + thisDigit
+ } else {
+ value = thisDigit
+ }
+ if (value > 9999) value = 9999
+ mArgs[mArgIndex] = value
+ }
+ continueSequence(mEscapeState)
+ } else if (b == ';'.code || b == ':'.code) {
+ if (mArgIndex + 1 < mArgs.size) {
+ mArgIndex++
+ if (b == ':'.code) {
+ mArgsSubParamsBitSet = mArgsSubParamsBitSet or (1 shl mArgIndex)
+ }
+ } else {
+ logError("Too many parameters when in state: $mEscapeState")
+ }
+ continueSequence(mEscapeState)
+ } else {
+ unknownSequence(b)
+ }
+ }
+
+ private fun getArg0(defaultValue: Int): Int = getArg(0, defaultValue, true)
+
+ private fun getArg1(defaultValue: Int): Int = getArg(1, defaultValue, true)
+
+ private fun getArg(index: Int, defaultValue: Int, treatZeroAsDefault: Boolean): Int {
+ var result = mArgs[index]
+ if (result < 0 || (result == 0 && treatZeroAsDefault)) {
+ result = defaultValue
+ }
+ return result
+ }
+
+ private fun collectOSCArgs(b: Int) {
+ if (mOSCOrDeviceControlArgs.length < MAX_OSC_STRING_LENGTH) {
+ mOSCOrDeviceControlArgs.appendCodePoint(b)
+ continueSequence(mEscapeState)
+ } else {
+ unknownSequence(b)
+ }
+ }
+
+ private fun unimplementedSequence(b: Int) {
+ logError("Unimplemented sequence char '${b.toChar()}' (U+${String.format("%04x", b)})")
+ finishSequence()
+ }
+
+ private fun unknownSequence(b: Int) {
+ logError("Unknown sequence char '${b.toChar()}' (numeric value=$b)")
+ finishSequence()
+ }
+
+ private fun unknownParameter(parameter: Int) {
+ logError("Unknown parameter: $parameter")
+ finishSequence()
+ }
+
+ private fun logError(errorType: String) {
+ if (LOG_ESCAPE_SEQUENCES) {
+ val buf = StringBuilder()
+ buf.append(errorType)
+ buf.append(", escapeState=")
+ buf.append(mEscapeState)
+ var firstArg = true
+ if (mArgIndex >= mArgs.size) mArgIndex = mArgs.size - 1
+ for (i in 0..mArgIndex) {
+ val value = mArgs[i]
+ if (value >= 0) {
+ if (firstArg) {
+ firstArg = false
+ buf.append(", args={")
+ } else {
+ buf.append(',')
+ }
+ buf.append(value)
+ }
+ }
+ if (!firstArg) buf.append('}')
+ finishSequenceAndLogError(buf.toString())
+ }
+ }
+
+ private fun finishSequenceAndLogError(error: String) {
+ if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error)
+ finishSequence()
+ }
+
+ private fun finishSequence() {
+ mEscapeState = ESC_NONE
+ }
+
+ private fun isDecsetInternalBitSet(bit: Int): Boolean {
+ return (mCurrentDecSetFlags and bit) != 0
+ }
+
+ private fun setDecsetinternalBit(internalBit: Int, set: Boolean) {
+ if (set) {
+ // The mouse modes are mutually exclusive.
+ if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false)
+ } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false)
+ }
+ }
+ if (set) {
+ mCurrentDecSetFlags = mCurrentDecSetFlags or internalBit
+ } else {
+ mCurrentDecSetFlags = mCurrentDecSetFlags and internalBit.inv()
+ }
+ }
+
+ private fun setCursorRow(row: Int) {
+ mCursorRow = row
+ mAboutToAutoWrap = false
+ }
+
+ private fun setCursorCol(col: Int) {
+ mCursorCol = col
+ mAboutToAutoWrap = false
+ }
+
+ /** Set the cursor mode, but limit it to margins if [DECSET_BIT_ORIGIN_MODE] is enabled. */
+ private fun setCursorColRespectingOriginMode(col: Int) {
+ setCursorPosition(col, mCursorRow)
+ }
+
+ /** TODO: Better name, distinguished from [setCursorPosition] by not regarding origin mode. */
+ private fun setCursorRowCol(row: Int, col: Int) {
+ mCursorRow = maxOf(0, minOf(row, mRows - 1))
+ mCursorCol = maxOf(0, minOf(col, mColumns - 1))
+ mAboutToAutoWrap = false
+ }
+
+ fun getScrollCounter(): Int = mScrollCounter
+
+ fun clearScrollCounter() {
+ mScrollCounter = 0
+ }
+
+ fun isAutoScrollDisabled(): Boolean = mAutoScrollDisabled
+
+ fun toggleAutoScrollDisabled() {
+ mAutoScrollDisabled = !mAutoScrollDisabled
+ }
+
+ /** Reset terminal state so user can interact with it regardless of present state. */
+ fun reset() {
+ setCursorStyle()
+ mArgIndex = 0
+ mContinueSequence = false
+ mEscapeState = ESC_NONE
+ mInsertMode = false
+ mTopMargin = 0
+ mLeftMargin = 0
+ mBottomMargin = mRows
+ mRightMargin = mColumns
+ mAboutToAutoWrap = false
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND
+ mSavedStateMain.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND
+ mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND
+ mSavedStateMain.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND
+ mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND
+ setDefaultTabStops()
+
+ mUseLineDrawingG0 = false
+ mUseLineDrawingG1 = false
+ mUseLineDrawingUsesG0 = true
+
+ mSavedStateMain.mSavedCursorRow = 0
+ mSavedStateMain.mSavedCursorCol = 0
+ mSavedStateMain.mSavedEffect = 0
+ mSavedStateMain.mSavedDecFlags = 0
+ mSavedStateAlt.mSavedCursorRow = 0
+ mSavedStateAlt.mSavedCursorCol = 0
+ mSavedStateAlt.mSavedEffect = 0
+ mSavedStateAlt.mSavedDecFlags = 0
+ mCurrentDecSetFlags = 0
+ // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen:
+ setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true)
+ setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true)
+ mSavedDecSetFlags = mCurrentDecSetFlags
+ mSavedStateMain.mSavedDecFlags = mCurrentDecSetFlags
+ mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags
+
+ // XXX: Should we set terminal driver back to IUTF8 with termios?
+ mUtf8Index = 0
+ mUtf8ToFollow = 0
+
+ mColors.reset()
+ mSession.onColorsChanged()
+ }
+
+ fun getSelectedText(x1: Int, y1: Int, x2: Int, y2: Int): String {
+ return mScreen.getSelectedText(x1, y1, x2, y2)
+ }
+
+ /** Get the terminal session's title (null if not set). */
+ val title: String?
+ get() = mTitle
+
+ /** Change the terminal session's title. */
+ private fun setTitle(newTitle: String?) {
+ val oldTitle = mTitle
+ mTitle = newTitle
+ if (oldTitle != newTitle) {
+ mSession.titleChanged(oldTitle, newTitle)
+ }
+ }
+
+ /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */
+ fun paste(text: String) {
+ // First: Always remove escape key and C1 control characters [0x80,0x9F]:
+ var processed = text.replace("(\u001B|[\u0080-\u009F])".toRegex(), "")
+ // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r).
+ processed = processed.replace("\r?\n".toRegex(), "\r")
+
+ // Then: Implement bracketed paste mode if enabled:
+ val bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE)
+ if (bracketed) mSession.write("\u001b[200~")
+ mSession.write(processed)
+ if (bracketed) mSession.write("\u001b[201~")
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECSC */
+ class SavedScreenState {
+ /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */
+ @JvmField var mSavedCursorRow = 0
+ @JvmField var mSavedCursorCol = 0
+ @JvmField var mSavedEffect = 0
+ @JvmField var mSavedForeColor = 0
+ @JvmField var mSavedBackColor = 0
+ @JvmField var mSavedDecFlags = 0
+ @JvmField var mUseLineDrawingG0 = false
+ @JvmField var mUseLineDrawingG1 = false
+ @JvmField var mUseLineDrawingUsesG0 = true
+ }
+
+ override fun toString(): String {
+ return "TerminalEmulator[size=${mScreen.mColumns}x${mScreen.mScreenRows}, margins={$mTopMargin,$mRightMargin,$mBottomMargin,$mLeftMargin}]"
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalOutput.kt b/app/src/main/java/com/termux/terminal/TerminalOutput.kt
new file mode 100644
index 0000000..532133b
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalOutput.kt
@@ -0,0 +1,31 @@
+package com.termux.terminal
+
+import java.nio.charset.StandardCharsets
+
+/** A client which receives callbacks from events triggered by feeding input to a [TerminalEmulator]. */
+abstract class TerminalOutput {
+
+ /** Write a string using the UTF-8 encoding to the terminal client. */
+ fun write(data: String?) {
+ if (data == null) return
+ val bytes = data.toByteArray(StandardCharsets.UTF_8)
+ write(bytes, 0, bytes.size)
+ }
+
+ /** Write bytes to the terminal client. */
+ abstract fun write(data: ByteArray, offset: Int, count: Int)
+
+ /** Notify the terminal client that the terminal title has changed. */
+ abstract fun titleChanged(oldTitle: String?, newTitle: String?)
+
+ /** Notify the terminal client that text should be copied to clipboard. */
+ abstract fun onCopyTextToClipboard(text: String?)
+
+ /** Notify the terminal client that text should be pasted from clipboard. */
+ abstract fun onPasteTextFromClipboard()
+
+ /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */
+ abstract fun onBell()
+
+ abstract fun onColorsChanged()
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalRow.kt b/app/src/main/java/com/termux/terminal/TerminalRow.kt
new file mode 100644
index 0000000..828b158
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalRow.kt
@@ -0,0 +1,279 @@
+package com.termux.terminal
+
+import java.util.Arrays
+
+/**
+ * A row in a terminal, composed of a fixed number of cells.
+ *
+ * The text in the row is stored in a char[] array, [mText], for quick access during rendering.
+ */
+class TerminalRow(
+ /** The number of columns in this terminal row. */
+ private val mColumns: Int,
+ style: Long
+) {
+ /** The text filling this terminal row. */
+ @JvmField
+ var mText: CharArray
+
+ /** The number of java chars used in [mText]. */
+ private var mSpaceUsed: Short = 0
+
+ /** If this row has been line wrapped due to text output at the end of line. */
+ @JvmField
+ var mLineWrap: Boolean = false
+
+ /** The style bits of each cell in the row. See [TextStyle]. */
+ @JvmField
+ val mStyle: LongArray
+
+ /** If this row might contain chars with width != 1, used for deactivating fast path */
+ @JvmField
+ var mHasNonOneWidthOrSurrogateChars: Boolean = false
+
+ init {
+ mText = CharArray((SPARE_CAPACITY_FACTOR * mColumns).toInt())
+ mStyle = LongArray(mColumns)
+ clear(style)
+ }
+
+ /** NOTE: The sourceX2 is exclusive. */
+ fun copyInterval(line: TerminalRow, sourceX1: Int, sourceX2: Int, destinationX: Int) {
+ var srcX1 = sourceX1
+ var destX = destinationX
+ mHasNonOneWidthOrSurrogateChars = mHasNonOneWidthOrSurrogateChars or line.mHasNonOneWidthOrSurrogateChars
+ val x1 = line.findStartOfColumn(srcX1)
+ val x2 = line.findStartOfColumn(sourceX2)
+ var startingFromSecondHalfOfWideChar = srcX1 > 0 && line.wideDisplayCharacterStartingAt(srcX1 - 1)
+ val sourceChars = if (this === line) Arrays.copyOf(line.mText, line.mText.size) else line.mText
+ var latestNonCombiningWidth = 0
+ var i = x1
+ while (i < x2) {
+ var sourceChar = sourceChars[i]
+ var codePoint = if (Character.isHighSurrogate(sourceChar)) Character.toCodePoint(sourceChar, sourceChars[++i]) else sourceChar.code
+ if (startingFromSecondHalfOfWideChar) {
+ // Just treat copying second half of wide char as copying whitespace.
+ codePoint = ' '.code
+ startingFromSecondHalfOfWideChar = false
+ }
+ val w = WcWidth.width(codePoint)
+ if (w > 0) {
+ destX += latestNonCombiningWidth
+ srcX1 += latestNonCombiningWidth
+ latestNonCombiningWidth = w
+ }
+ setChar(destX, codePoint, line.getStyle(srcX1))
+ i++
+ }
+ }
+
+ val spaceUsed: Int
+ get() = mSpaceUsed.toInt()
+
+ /** Note that the column may end of second half of wide character. */
+ fun findStartOfColumn(column: Int): Int {
+ if (column == mColumns) return spaceUsed
+
+ var currentColumn = 0
+ var currentCharIndex = 0
+ while (true) {
+ var newCharIndex = currentCharIndex
+ val c = mText[newCharIndex++]
+ val isHigh = Character.isHighSurrogate(c)
+ val codePoint = if (isHigh) Character.toCodePoint(c, mText[newCharIndex++]) else c.code
+ val wcwidth = WcWidth.width(codePoint)
+ if (wcwidth > 0) {
+ currentColumn += wcwidth
+ if (currentColumn == column) {
+ while (newCharIndex < mSpaceUsed) {
+ // Skip combining chars.
+ if (Character.isHighSurrogate(mText[newCharIndex])) {
+ if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) {
+ newCharIndex += 2
+ } else {
+ break
+ }
+ } else if (WcWidth.width(mText[newCharIndex].code) <= 0) {
+ newCharIndex++
+ } else {
+ break
+ }
+ }
+ return newCharIndex
+ } else if (currentColumn > column) {
+ // Wide column going past end.
+ return currentCharIndex
+ }
+ }
+ currentCharIndex = newCharIndex
+ }
+ }
+
+ private fun wideDisplayCharacterStartingAt(column: Int): Boolean {
+ var currentCharIndex = 0
+ var currentColumn = 0
+ while (currentCharIndex < mSpaceUsed) {
+ val c = mText[currentCharIndex++]
+ val codePoint = if (Character.isHighSurrogate(c)) Character.toCodePoint(c, mText[currentCharIndex++]) else c.code
+ val wcwidth = WcWidth.width(codePoint)
+ if (wcwidth > 0) {
+ if (currentColumn == column && wcwidth == 2) return true
+ currentColumn += wcwidth
+ if (currentColumn > column) return false
+ }
+ }
+ return false
+ }
+
+ fun clear(style: Long) {
+ Arrays.fill(mText, ' ')
+ Arrays.fill(mStyle, style)
+ mSpaceUsed = mColumns.toShort()
+ mHasNonOneWidthOrSurrogateChars = false
+ }
+
+ // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
+ fun setChar(columnToSet: Int, codePoint: Int, style: Long) {
+ if (columnToSet < 0 || columnToSet >= mStyle.size)
+ throw IllegalArgumentException("TerminalRow.setChar(): columnToSet=$columnToSet, codePoint=$codePoint, style=$style")
+
+ mStyle[columnToSet] = style
+
+ val newCodePointDisplayWidth = WcWidth.width(codePoint)
+
+ // Fast path when we don't have any chars with width != 1
+ if (!mHasNonOneWidthOrSurrogateChars) {
+ if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
+ mHasNonOneWidthOrSurrogateChars = true
+ } else {
+ mText[columnToSet] = codePoint.toChar()
+ return
+ }
+ }
+
+ val newIsCombining = newCodePointDisplayWidth <= 0
+ var actualColumnToSet = columnToSet
+
+ val wasExtraColForWideChar = columnToSet > 0 && wideDisplayCharacterStartingAt(columnToSet - 1)
+
+ if (newIsCombining) {
+ // When standing at second half of wide character and inserting combining:
+ if (wasExtraColForWideChar) actualColumnToSet--
+ } else {
+ // Check if we are overwriting the second half of a wide character starting at the previous column:
+ if (wasExtraColForWideChar) setChar(columnToSet - 1, ' '.code, style)
+ // Check if we are overwriting the first half of a wide character starting at the next column:
+ val overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1)
+ if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' '.code, style)
+ }
+
+ var text = mText
+ val oldStartOfColumnIndex = findStartOfColumn(actualColumnToSet)
+ val oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex)
+
+ // Get the number of elements in the mText array this column uses now
+ val oldCharactersUsedForColumn: Int
+ if (actualColumnToSet + oldCodePointDisplayWidth < mColumns) {
+ val oldEndOfColumnIndex = findStartOfColumn(actualColumnToSet + oldCodePointDisplayWidth)
+ oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex
+ } else {
+ // Last character.
+ oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex
+ }
+
+ // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters.
+ if (newIsCombining) {
+ val combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn)
+ if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN)
+ return
+ }
+
+ // Find how many chars this column will need
+ var newCharactersUsedForColumn = Character.charCount(codePoint)
+ if (newIsCombining) {
+ // Combining characters are added to the contents of the column instead of overwriting them, so that they
+ // modify the existing contents.
+ // FIXME: Unassigned characters also get width=0.
+ newCharactersUsedForColumn += oldCharactersUsedForColumn
+ }
+
+ val oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn
+ val newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn
+
+ val javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn
+ if (javaCharDifference > 0) {
+ // Shift the rest of the line right.
+ val oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex
+ if (mSpaceUsed + javaCharDifference > text.size) {
+ // We need to grow the array
+ val newText = CharArray(text.size + mColumns)
+ System.arraycopy(text, 0, newText, 0, oldNextColumnIndex)
+ System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn)
+ mText = newText
+ text = newText
+ } else {
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn)
+ }
+ } else if (javaCharDifference < 0) {
+ // Shift the rest of the line left.
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex)
+ }
+ mSpaceUsed = (mSpaceUsed + javaCharDifference).toShort()
+
+ // Store char. A combining character is stored at the end of the existing contents so that it modifies them:
+ Character.toChars(codePoint, text, oldStartOfColumnIndex + (if (newIsCombining) oldCharactersUsedForColumn else 0))
+
+ if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
+ // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character.
+ if (mSpaceUsed + 1 > text.size) {
+ val newText = CharArray(text.size + mColumns)
+ System.arraycopy(text, 0, newText, 0, newNextColumnIndex)
+ System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex)
+ mText = newText
+ text = newText
+ } else {
+ System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex)
+ }
+ text[newNextColumnIndex] = ' '
+
+ mSpaceUsed++
+ } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
+ if (actualColumnToSet == mColumns - 1) {
+ throw IllegalArgumentException("Cannot put wide character in last column")
+ } else if (actualColumnToSet == mColumns - 2) {
+ // Truncate the line to the second part of this wide char:
+ mSpaceUsed = newNextColumnIndex.toShort()
+ } else {
+ // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the
+ // check at the beginning of this method we know that we are not overwriting a wide char.
+ val newNextNextColumnIndex = newNextColumnIndex + (if (Character.isHighSurrogate(mText[newNextColumnIndex])) 2 else 1)
+ val nextLen = newNextNextColumnIndex - newNextColumnIndex
+
+ // Shift the array leftwards.
+ System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex)
+ mSpaceUsed = (mSpaceUsed - nextLen).toShort()
+ }
+ }
+ }
+
+ fun isBlank(): Boolean {
+ for (charIndex in 0 until spaceUsed) {
+ if (mText[charIndex] != ' ') return false
+ }
+ return true
+ }
+
+ fun getStyle(column: Int): Long {
+ return mStyle[column]
+ }
+
+ companion object {
+ private const val SPARE_CAPACITY_FACTOR = 1.5f
+
+ /**
+ * Max combining characters that can exist in a column, that are separate from the base character
+ * itself. Any additional combining characters will be ignored and not added to the column.
+ */
+ private const val MAX_COMBINING_CHARACTERS_PER_COLUMN = 15
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalSession.kt b/app/src/main/java/com/termux/terminal/TerminalSession.kt
new file mode 100644
index 0000000..d2a1c16
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalSession.kt
@@ -0,0 +1,82 @@
+package com.termux.terminal
+
+class TerminalSession(
+ private val shellWriter: (ByteArray, Int, Int) -> Unit,
+ private val onScreenUpdated: () -> Unit,
+ private val onCopyTextToClipboardRequested: (String) -> Unit,
+ private val onPasteTextFromClipboardRequested: () -> Unit,
+ private val onBellRequested: () -> Unit,
+) : TerminalOutput() {
+
+ var emulator: TerminalEmulator = createEmulator()
+ private set
+
+ fun append(data: ByteArray, count: Int) {
+ if (count <= 0) return
+ emulator.append(data, count)
+ onScreenUpdated()
+ }
+
+ fun reset() {
+ emulator = createEmulator()
+ onScreenUpdated()
+ }
+
+ fun updateSize(
+ columns: Int,
+ rows: Int,
+ cellWidthPixels: Int,
+ cellHeightPixels: Int,
+ ) {
+ emulator.resize(columns, rows, cellWidthPixels, cellHeightPixels)
+ onScreenUpdated()
+ }
+
+ override fun write(data: ByteArray, offset: Int, count: Int) {
+ shellWriter(data, offset, count)
+ }
+
+ fun writeCodePoint(prependEscape: Boolean, codePoint: Int) {
+ if (codePoint > 0x10FFFF || codePoint in 0xD800..0xDFFF) {
+ throw IllegalArgumentException("Invalid code point: $codePoint")
+ }
+ val utf8 = StringBuilder()
+ if (prependEscape) {
+ utf8.append('\u001B')
+ }
+ utf8.appendCodePoint(codePoint)
+ write(utf8.toString())
+ }
+
+ override fun titleChanged(oldTitle: String?, newTitle: String?) = Unit
+
+ override fun onCopyTextToClipboard(text: String?) {
+ if (!text.isNullOrEmpty()) {
+ onCopyTextToClipboardRequested(text)
+ }
+ }
+
+ override fun onPasteTextFromClipboard() {
+ onPasteTextFromClipboardRequested()
+ }
+
+ override fun onBell() {
+ onBellRequested()
+ }
+
+ override fun onColorsChanged() {
+ onScreenUpdated()
+ }
+
+ private fun createEmulator(): TerminalEmulator {
+ return TerminalEmulator(
+ mSession = this,
+ columns = 80,
+ rows = 24,
+ cellWidthPixels = 1,
+ cellHeightPixels = 1,
+ transcriptRows = null,
+ client = null,
+ )
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/TerminalSessionClient.kt b/app/src/main/java/com/termux/terminal/TerminalSessionClient.kt
new file mode 100644
index 0000000..23a438c
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TerminalSessionClient.kt
@@ -0,0 +1,43 @@
+package com.termux.terminal
+
+/**
+ * The interface for communication between [TerminalSession] and its client. It is used to
+ * send callbacks to the client when [TerminalSession] changes or for sending other
+ * back data to the client like logs.
+ */
+interface TerminalSessionClient {
+
+ fun onTextChanged(changedSession: TerminalSession)
+
+ fun onTitleChanged(changedSession: TerminalSession)
+
+ fun onSessionFinished(finishedSession: TerminalSession)
+
+ fun onCopyTextToClipboard(session: TerminalSession, text: String?)
+
+ fun onPasteTextFromClipboard(session: TerminalSession?)
+
+ fun onBell(session: TerminalSession)
+
+ fun onColorsChanged(session: TerminalSession)
+
+ fun onTerminalCursorStateChange(state: Boolean)
+
+ fun setTerminalShellPid(session: TerminalSession, pid: Int)
+
+ fun getTerminalCursorStyle(): Int?
+
+ fun logError(tag: String?, message: String?)
+
+ fun logWarn(tag: String?, message: String?)
+
+ fun logInfo(tag: String?, message: String?)
+
+ fun logDebug(tag: String?, message: String?)
+
+ fun logVerbose(tag: String?, message: String?)
+
+ fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?)
+
+ fun logStackTrace(tag: String?, e: Exception?)
+}
diff --git a/app/src/main/java/com/termux/terminal/TextStyle.kt b/app/src/main/java/com/termux/terminal/TextStyle.kt
new file mode 100644
index 0000000..9cc87e8
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/TextStyle.kt
@@ -0,0 +1,88 @@
+package com.termux.terminal
+
+/**
+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal
+ * row in [TerminalRow.mStyle].
+ *
+ * The bit layout is:
+ * - 16 flags (11 currently used).
+ * - 24 for foreground color (only 9 first bits if a color index).
+ * - 24 for background color (only 9 first bits if a color index).
+ */
+object TextStyle {
+
+ const val CHARACTER_ATTRIBUTE_BOLD = 1
+ const val CHARACTER_ATTRIBUTE_ITALIC = 1 shl 1
+ const val CHARACTER_ATTRIBUTE_UNDERLINE = 1 shl 2
+ const val CHARACTER_ATTRIBUTE_BLINK = 1 shl 3
+ const val CHARACTER_ATTRIBUTE_INVERSE = 1 shl 4
+ const val CHARACTER_ATTRIBUTE_INVISIBLE = 1 shl 5
+ const val CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 shl 6
+ /**
+ * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable.
+ *
+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that
+ * come after it as erasable from the screen.
+ */
+ const val CHARACTER_ATTRIBUTE_PROTECTED = 1 shl 7
+ /** Dim colors. Also known as faint or half intensity. */
+ const val CHARACTER_ATTRIBUTE_DIM = 1 shl 8
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private const val CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 shl 9
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private const val CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND = 1 shl 10
+
+ const val COLOR_INDEX_FOREGROUND = 256
+ const val COLOR_INDEX_BACKGROUND = 257
+ const val COLOR_INDEX_CURSOR = 258
+
+ /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */
+ const val NUM_INDEXED_COLORS = 259
+
+ /** Normal foreground and background colors and no effects. */
+ @JvmField
+ val NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0)
+
+ @JvmStatic
+ fun encode(foreColor: Int, backColor: Int, effect: Int): Long {
+ var result = (effect and 0b111111111).toLong()
+ if ((0xff000000.toInt() and foreColor) == 0xff000000.toInt()) {
+ // 24-bit color.
+ result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong() or ((foreColor.toLong() and 0x00ffffffL) shl 40)
+ } else {
+ // Indexed color.
+ result = result or ((foreColor.toLong() and 0b111111111L) shl 40)
+ }
+ if ((0xff000000.toInt() and backColor) == 0xff000000.toInt()) {
+ // 24-bit color.
+ result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong() or ((backColor.toLong() and 0x00ffffffL) shl 16)
+ } else {
+ // Indexed color.
+ result = result or ((backColor.toLong() and 0b111111111L) shl 16)
+ }
+ return result
+ }
+
+ @JvmStatic
+ fun decodeForeColor(style: Long): Int {
+ return if ((style and CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong()) == 0L) {
+ ((style ushr 40) and 0b111111111L).toInt()
+ } else {
+ 0xff000000.toInt() or ((style ushr 40) and 0x00ffffffL).toInt()
+ }
+ }
+
+ @JvmStatic
+ fun decodeBackColor(style: Long): Int {
+ return if ((style and CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong()) == 0L) {
+ ((style ushr 16) and 0b111111111L).toInt()
+ } else {
+ 0xff000000.toInt() or ((style ushr 16) and 0x00ffffffL).toInt()
+ }
+ }
+
+ @JvmStatic
+ fun decodeEffect(style: Long): Int {
+ return (style and 0b11111111111L).toInt()
+ }
+}
diff --git a/app/src/main/java/com/termux/terminal/WcWidth.kt b/app/src/main/java/com/termux/terminal/WcWidth.kt
new file mode 100644
index 0000000..609e4d2
--- /dev/null
+++ b/app/src/main/java/com/termux/terminal/WcWidth.kt
@@ -0,0 +1,571 @@
+package com.termux.terminal
+
+/**
+ * Implementation of wcwidth(3) for Unicode 15.
+ *
+ * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
+ *
+ * IMPORTANT:
+ * Must be kept in sync with the following:
+ * https://github.com/termux/wcwidth
+ * https://github.com/termux/libandroid-support
+ * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
+ */
+object WcWidth {
+
+ // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
+ // from https://github.com/jquast/wcwidth/pull/64
+ // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
+ private val ZERO_WIDTH = arrayOf(
+
+ intArrayOf(0x00300, 0x0036f), // Combining Grave Accent ..Combining Latin Small Le
+ intArrayOf(0x00483, 0x00489), // Combining Cyrillic Titlo..Combining Cyrillic Milli
+ intArrayOf(0x00591, 0x005bd), // Hebrew Accent Etnahta ..Hebrew Point Meteg
+ intArrayOf(0x005bf, 0x005bf), // Hebrew Point Rafe ..Hebrew Point Rafe
+ intArrayOf(0x005c1, 0x005c2), // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
+ intArrayOf(0x005c4, 0x005c5), // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
+ intArrayOf(0x005c7, 0x005c7), // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
+ intArrayOf(0x00610, 0x0061a), // Arabic Sign Sallallahou ..Arabic Small Kasra
+ intArrayOf(0x0064b, 0x0065f), // Arabic Fathatan ..Arabic Wavy Hamza Below
+ intArrayOf(0x00670, 0x00670), // Arabic Letter Superscrip..Arabic Letter Superscrip
+ intArrayOf(0x006d6, 0x006dc), // Arabic Small High Ligatu..Arabic Small High Seen
+ intArrayOf(0x006df, 0x006e4), // Arabic Small High Rounde..Arabic Small High Madda
+ intArrayOf(0x006e7, 0x006e8), // Arabic Small High Yeh ..Arabic Small High Noon
+ intArrayOf(0x006ea, 0x006ed), // Arabic Empty Centre Low ..Arabic Small Low Meem
+ intArrayOf(0x00711, 0x00711), // Syriac Letter Superscrip..Syriac Letter Superscrip
+ intArrayOf(0x00730, 0x0074a), // Syriac Pthaha Above ..Syriac Barrekh
+ intArrayOf(0x007a6, 0x007b0), // Thaana Abafili ..Thaana Sukun
+ intArrayOf(0x007eb, 0x007f3), // Nko Combining Short High..Nko Combining Double Dot
+ intArrayOf(0x007fd, 0x007fd), // Nko Dantayalan ..Nko Dantayalan
+ intArrayOf(0x00816, 0x00819), // Samaritan Mark In ..Samaritan Mark Dagesh
+ intArrayOf(0x0081b, 0x00823), // Samaritan Mark Epentheti..Samaritan Vowel Sign A
+ intArrayOf(0x00825, 0x00827), // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
+ intArrayOf(0x00829, 0x0082d), // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
+ intArrayOf(0x00859, 0x0085b), // Mandaic Affrication Mark..Mandaic Gemination Mark
+ intArrayOf(0x00898, 0x0089f), // Arabic Small High Word A..Arabic Half Madda Over M
+ intArrayOf(0x008ca, 0x008e1), // Arabic Small High Farsi ..Arabic Small High Sign S
+ intArrayOf(0x008e3, 0x00902), // Arabic Turned Damma Belo..Devanagari Sign Anusvara
+ intArrayOf(0x0093a, 0x0093a), // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
+ intArrayOf(0x0093c, 0x0093c), // Devanagari Sign Nukta ..Devanagari Sign Nukta
+ intArrayOf(0x00941, 0x00948), // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
+ intArrayOf(0x0094d, 0x0094d), // Devanagari Sign Virama ..Devanagari Sign Virama
+ intArrayOf(0x00951, 0x00957), // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
+ intArrayOf(0x00962, 0x00963), // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
+ intArrayOf(0x00981, 0x00981), // Bengali Sign Candrabindu..Bengali Sign Candrabindu
+ intArrayOf(0x009bc, 0x009bc), // Bengali Sign Nukta ..Bengali Sign Nukta
+ intArrayOf(0x009c1, 0x009c4), // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
+ intArrayOf(0x009cd, 0x009cd), // Bengali Sign Virama ..Bengali Sign Virama
+ intArrayOf(0x009e2, 0x009e3), // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
+ intArrayOf(0x009fe, 0x009fe), // Bengali Sandhi Mark ..Bengali Sandhi Mark
+ intArrayOf(0x00a01, 0x00a02), // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
+ intArrayOf(0x00a3c, 0x00a3c), // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
+ intArrayOf(0x00a41, 0x00a42), // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
+ intArrayOf(0x00a47, 0x00a48), // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
+ intArrayOf(0x00a4b, 0x00a4d), // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
+ intArrayOf(0x00a51, 0x00a51), // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
+ intArrayOf(0x00a70, 0x00a71), // Gurmukhi Tippi ..Gurmukhi Addak
+ intArrayOf(0x00a75, 0x00a75), // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
+ intArrayOf(0x00a81, 0x00a82), // Gujarati Sign Candrabind..Gujarati Sign Anusvara
+ intArrayOf(0x00abc, 0x00abc), // Gujarati Sign Nukta ..Gujarati Sign Nukta
+ intArrayOf(0x00ac1, 0x00ac5), // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
+ intArrayOf(0x00ac7, 0x00ac8), // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
+ intArrayOf(0x00acd, 0x00acd), // Gujarati Sign Virama ..Gujarati Sign Virama
+ intArrayOf(0x00ae2, 0x00ae3), // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
+ intArrayOf(0x00afa, 0x00aff), // Gujarati Sign Sukun ..Gujarati Sign Two-circle
+ intArrayOf(0x00b01, 0x00b01), // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
+ intArrayOf(0x00b3c, 0x00b3c), // Oriya Sign Nukta ..Oriya Sign Nukta
+ intArrayOf(0x00b3f, 0x00b3f), // Oriya Vowel Sign I ..Oriya Vowel Sign I
+ intArrayOf(0x00b41, 0x00b44), // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
+ intArrayOf(0x00b4d, 0x00b4d), // Oriya Sign Virama ..Oriya Sign Virama
+ intArrayOf(0x00b55, 0x00b56), // Oriya Sign Overline ..Oriya Ai Length Mark
+ intArrayOf(0x00b62, 0x00b63), // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
+ intArrayOf(0x00b82, 0x00b82), // Tamil Sign Anusvara ..Tamil Sign Anusvara
+ intArrayOf(0x00bc0, 0x00bc0), // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
+ intArrayOf(0x00bcd, 0x00bcd), // Tamil Sign Virama ..Tamil Sign Virama
+ intArrayOf(0x00c00, 0x00c00), // Telugu Sign Combining Ca..Telugu Sign Combining Ca
+ intArrayOf(0x00c04, 0x00c04), // Telugu Sign Combining An..Telugu Sign Combining An
+ intArrayOf(0x00c3c, 0x00c3c), // Telugu Sign Nukta ..Telugu Sign Nukta
+ intArrayOf(0x00c3e, 0x00c40), // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
+ intArrayOf(0x00c46, 0x00c48), // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
+ intArrayOf(0x00c4a, 0x00c4d), // Telugu Vowel Sign O ..Telugu Sign Virama
+ intArrayOf(0x00c55, 0x00c56), // Telugu Length Mark ..Telugu Ai Length Mark
+ intArrayOf(0x00c62, 0x00c63), // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
+ intArrayOf(0x00c81, 0x00c81), // Kannada Sign Candrabindu..Kannada Sign Candrabindu
+ intArrayOf(0x00cbc, 0x00cbc), // Kannada Sign Nukta ..Kannada Sign Nukta
+ intArrayOf(0x00cbf, 0x00cbf), // Kannada Vowel Sign I ..Kannada Vowel Sign I
+ intArrayOf(0x00cc6, 0x00cc6), // Kannada Vowel Sign E ..Kannada Vowel Sign E
+ intArrayOf(0x00ccc, 0x00ccd), // Kannada Vowel Sign Au ..Kannada Sign Virama
+ intArrayOf(0x00ce2, 0x00ce3), // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
+ intArrayOf(0x00d00, 0x00d01), // Malayalam Sign Combining..Malayalam Sign Candrabin
+ intArrayOf(0x00d3b, 0x00d3c), // Malayalam Sign Vertical ..Malayalam Sign Circular
+ intArrayOf(0x00d41, 0x00d44), // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
+ intArrayOf(0x00d4d, 0x00d4d), // Malayalam Sign Virama ..Malayalam Sign Virama
+ intArrayOf(0x00d62, 0x00d63), // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
+ intArrayOf(0x00d81, 0x00d81), // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
+ intArrayOf(0x00dca, 0x00dca), // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
+ intArrayOf(0x00dd2, 0x00dd4), // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
+ intArrayOf(0x00dd6, 0x00dd6), // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
+ intArrayOf(0x00e31, 0x00e31), // Thai Character Mai Han-a..Thai Character Mai Han-a
+ intArrayOf(0x00e34, 0x00e3a), // Thai Character Sara I ..Thai Character Phinthu
+ intArrayOf(0x00e47, 0x00e4e), // Thai Character Maitaikhu..Thai Character Yamakkan
+ intArrayOf(0x00eb1, 0x00eb1), // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
+ intArrayOf(0x00eb4, 0x00ebc), // Lao Vowel Sign I ..Lao Semivowel Sign Lo
+ intArrayOf(0x00ec8, 0x00ece), // Lao Tone Mai Ek ..(nil)
+ intArrayOf(0x00f18, 0x00f19), // Tibetan Astrological Sig..Tibetan Astrological Sig
+ intArrayOf(0x00f35, 0x00f35), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ intArrayOf(0x00f37, 0x00f37), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ intArrayOf(0x00f39, 0x00f39), // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
+ intArrayOf(0x00f71, 0x00f7e), // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
+ intArrayOf(0x00f80, 0x00f84), // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
+ intArrayOf(0x00f86, 0x00f87), // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
+ intArrayOf(0x00f8d, 0x00f97), // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
+ intArrayOf(0x00f99, 0x00fbc), // Tibetan Subjoined Letter..Tibetan Subjoined Letter
+ intArrayOf(0x00fc6, 0x00fc6), // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
+ intArrayOf(0x0102d, 0x01030), // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
+ intArrayOf(0x01032, 0x01037), // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
+ intArrayOf(0x01039, 0x0103a), // Myanmar Sign Virama ..Myanmar Sign Asat
+ intArrayOf(0x0103d, 0x0103e), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ intArrayOf(0x01058, 0x01059), // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
+ intArrayOf(0x0105e, 0x01060), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ intArrayOf(0x01071, 0x01074), // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
+ intArrayOf(0x01082, 0x01082), // Myanmar Consonant Sign S..Myanmar Consonant Sign S
+ intArrayOf(0x01085, 0x01086), // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
+ intArrayOf(0x0108d, 0x0108d), // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
+ intArrayOf(0x0109d, 0x0109d), // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
+ intArrayOf(0x0135d, 0x0135f), // Ethiopic Combining Gemin..Ethiopic Combining Gemin
+ intArrayOf(0x01712, 0x01714), // Tagalog Vowel Sign I ..Tagalog Sign Virama
+ intArrayOf(0x01732, 0x01733), // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
+ intArrayOf(0x01752, 0x01753), // Buhid Vowel Sign I ..Buhid Vowel Sign U
+ intArrayOf(0x01772, 0x01773), // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
+ intArrayOf(0x017b4, 0x017b5), // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
+ intArrayOf(0x017b7, 0x017bd), // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
+ intArrayOf(0x017c6, 0x017c6), // Khmer Sign Nikahit ..Khmer Sign Nikahit
+ intArrayOf(0x017c9, 0x017d3), // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
+ intArrayOf(0x017dd, 0x017dd), // Khmer Sign Atthacan ..Khmer Sign Atthacan
+ intArrayOf(0x0180b, 0x0180d), // Mongolian Free Variation..Mongolian Free Variation
+ intArrayOf(0x0180f, 0x0180f), // Mongolian Free Variation..Mongolian Free Variation
+ intArrayOf(0x01885, 0x01886), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ intArrayOf(0x018a9, 0x018a9), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ intArrayOf(0x01920, 0x01922), // Limbu Vowel Sign A ..Limbu Vowel Sign U
+ intArrayOf(0x01927, 0x01928), // Limbu Vowel Sign E ..Limbu Vowel Sign O
+ intArrayOf(0x01932, 0x01932), // Limbu Small Letter Anusv..Limbu Small Letter Anusv
+ intArrayOf(0x01939, 0x0193b), // Limbu Sign Mukphreng ..Limbu Sign Sa-i
+ intArrayOf(0x01a17, 0x01a18), // Buginese Vowel Sign I ..Buginese Vowel Sign U
+ intArrayOf(0x01a1b, 0x01a1b), // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
+ intArrayOf(0x01a56, 0x01a56), // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
+ intArrayOf(0x01a58, 0x01a5e), // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
+ intArrayOf(0x01a60, 0x01a60), // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
+ intArrayOf(0x01a62, 0x01a62), // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
+ intArrayOf(0x01a65, 0x01a6c), // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
+ intArrayOf(0x01a73, 0x01a7c), // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
+ intArrayOf(0x01a7f, 0x01a7f), // Tai Tham Combining Crypt..Tai Tham Combining Crypt
+ intArrayOf(0x01ab0, 0x01ace), // Combining Doubled Circum..Combining Latin Small Le
+ intArrayOf(0x01b00, 0x01b03), // Balinese Sign Ulu Ricem ..Balinese Sign Surang
+ intArrayOf(0x01b34, 0x01b34), // Balinese Sign Rerekan ..Balinese Sign Rerekan
+ intArrayOf(0x01b36, 0x01b3a), // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
+ intArrayOf(0x01b3c, 0x01b3c), // Balinese Vowel Sign La L..Balinese Vowel Sign La L
+ intArrayOf(0x01b42, 0x01b42), // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
+ intArrayOf(0x01b6b, 0x01b73), // Balinese Musical Symbol ..Balinese Musical Symbol
+ intArrayOf(0x01b80, 0x01b81), // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
+ intArrayOf(0x01ba2, 0x01ba5), // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
+ intArrayOf(0x01ba8, 0x01ba9), // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
+ intArrayOf(0x01bab, 0x01bad), // Sundanese Sign Virama ..Sundanese Consonant Sign
+ intArrayOf(0x01be6, 0x01be6), // Batak Sign Tompi ..Batak Sign Tompi
+ intArrayOf(0x01be8, 0x01be9), // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
+ intArrayOf(0x01bed, 0x01bed), // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
+ intArrayOf(0x01bef, 0x01bf1), // Batak Vowel Sign U For S..Batak Consonant Sign H
+ intArrayOf(0x01c2c, 0x01c33), // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
+ intArrayOf(0x01c36, 0x01c37), // Lepcha Sign Ran ..Lepcha Sign Nukta
+ intArrayOf(0x01cd0, 0x01cd2), // Vedic Tone Karshana ..Vedic Tone Prenkha
+ intArrayOf(0x01cd4, 0x01ce0), // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
+ intArrayOf(0x01ce2, 0x01ce8), // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
+ intArrayOf(0x01ced, 0x01ced), // Vedic Sign Tiryak ..Vedic Sign Tiryak
+ intArrayOf(0x01cf4, 0x01cf4), // Vedic Tone Candra Above ..Vedic Tone Candra Above
+ intArrayOf(0x01cf8, 0x01cf9), // Vedic Tone Ring Above ..Vedic Tone Double Ring A
+ intArrayOf(0x01dc0, 0x01dff), // Combining Dotted Grave A..Combining Right Arrowhea
+ intArrayOf(0x020d0, 0x020f0), // Combining Left Harpoon A..Combining Asterisk Above
+ intArrayOf(0x02cef, 0x02cf1), // Coptic Combining Ni Abov..Coptic Combining Spiritu
+ intArrayOf(0x02d7f, 0x02d7f), // Tifinagh Consonant Joine..Tifinagh Consonant Joine
+ intArrayOf(0x02de0, 0x02dff), // Combining Cyrillic Lette..Combining Cyrillic Lette
+ intArrayOf(0x0302a, 0x0302d), // Ideographic Level Tone M..Ideographic Entering Ton
+ intArrayOf(0x03099, 0x0309a), // Combining Katakana-hirag..Combining Katakana-hirag
+ intArrayOf(0x0a66f, 0x0a672), // Combining Cyrillic Vzmet..Combining Cyrillic Thous
+ intArrayOf(0x0a674, 0x0a67d), // Combining Cyrillic Lette..Combining Cyrillic Payer
+ intArrayOf(0x0a69e, 0x0a69f), // Combining Cyrillic Lette..Combining Cyrillic Lette
+ intArrayOf(0x0a6f0, 0x0a6f1), // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
+ intArrayOf(0x0a802, 0x0a802), // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
+ intArrayOf(0x0a806, 0x0a806), // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
+ intArrayOf(0x0a80b, 0x0a80b), // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
+ intArrayOf(0x0a825, 0x0a826), // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
+ intArrayOf(0x0a82c, 0x0a82c), // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
+ intArrayOf(0x0a8c4, 0x0a8c5), // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
+ intArrayOf(0x0a8e0, 0x0a8f1), // Combining Devanagari Dig..Combining Devanagari Sig
+ intArrayOf(0x0a8ff, 0x0a8ff), // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
+ intArrayOf(0x0a926, 0x0a92d), // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
+ intArrayOf(0x0a947, 0x0a951), // Rejang Vowel Sign I ..Rejang Consonant Sign R
+ intArrayOf(0x0a980, 0x0a982), // Javanese Sign Panyangga ..Javanese Sign Layar
+ intArrayOf(0x0a9b3, 0x0a9b3), // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
+ intArrayOf(0x0a9b6, 0x0a9b9), // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
+ intArrayOf(0x0a9bc, 0x0a9bd), // Javanese Vowel Sign Pepe..Javanese Consonant Sign
+ intArrayOf(0x0a9e5, 0x0a9e5), // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
+ intArrayOf(0x0aa29, 0x0aa2e), // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
+ intArrayOf(0x0aa31, 0x0aa32), // Cham Vowel Sign Au ..Cham Vowel Sign Ue
+ intArrayOf(0x0aa35, 0x0aa36), // Cham Consonant Sign La ..Cham Consonant Sign Wa
+ intArrayOf(0x0aa43, 0x0aa43), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ intArrayOf(0x0aa4c, 0x0aa4c), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ intArrayOf(0x0aa7c, 0x0aa7c), // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
+ intArrayOf(0x0aab0, 0x0aab0), // Tai Viet Mai Kang ..Tai Viet Mai Kang
+ intArrayOf(0x0aab2, 0x0aab4), // Tai Viet Vowel I ..Tai Viet Vowel U
+ intArrayOf(0x0aab7, 0x0aab8), // Tai Viet Mai Khit ..Tai Viet Vowel Ia
+ intArrayOf(0x0aabe, 0x0aabf), // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
+ intArrayOf(0x0aac1, 0x0aac1), // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
+ intArrayOf(0x0aaec, 0x0aaed), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ intArrayOf(0x0aaf6, 0x0aaf6), // Meetei Mayek Virama ..Meetei Mayek Virama
+ intArrayOf(0x0abe5, 0x0abe5), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ intArrayOf(0x0abe8, 0x0abe8), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ intArrayOf(0x0abed, 0x0abed), // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
+ intArrayOf(0x0fb1e, 0x0fb1e), // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
+ intArrayOf(0x0fe00, 0x0fe0f), // Variation Selector-1 ..Variation Selector-16
+ intArrayOf(0x0fe20, 0x0fe2f), // Combining Ligature Left ..Combining Cyrillic Titlo
+ intArrayOf(0x101fd, 0x101fd), // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
+ intArrayOf(0x102e0, 0x102e0), // Coptic Epact Thousands M..Coptic Epact Thousands M
+ intArrayOf(0x10376, 0x1037a), // Combining Old Permic Let..Combining Old Permic Let
+ intArrayOf(0x10a01, 0x10a03), // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
+ intArrayOf(0x10a05, 0x10a06), // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
+ intArrayOf(0x10a0c, 0x10a0f), // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
+ intArrayOf(0x10a38, 0x10a3a), // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
+ intArrayOf(0x10a3f, 0x10a3f), // Kharoshthi Virama ..Kharoshthi Virama
+ intArrayOf(0x10ae5, 0x10ae6), // Manichaean Abbreviation ..Manichaean Abbreviation
+ intArrayOf(0x10d24, 0x10d27), // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
+ intArrayOf(0x10eab, 0x10eac), // Yezidi Combining Hamza M..Yezidi Combining Madda M
+ intArrayOf(0x10efd, 0x10eff), // (nil) ..(nil)
+ intArrayOf(0x10f46, 0x10f50), // Sogdian Combining Dot Be..Sogdian Combining Stroke
+ intArrayOf(0x10f82, 0x10f85), // Old Uyghur Combining Dot..Old Uyghur Combining Two
+ intArrayOf(0x11001, 0x11001), // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
+ intArrayOf(0x11038, 0x11046), // Brahmi Vowel Sign Aa ..Brahmi Virama
+ intArrayOf(0x11070, 0x11070), // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
+ intArrayOf(0x11073, 0x11074), // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
+ intArrayOf(0x1107f, 0x11081), // Brahmi Number Joiner ..Kaithi Sign Anusvara
+ intArrayOf(0x110b3, 0x110b6), // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
+ intArrayOf(0x110b9, 0x110ba), // Kaithi Sign Virama ..Kaithi Sign Nukta
+ intArrayOf(0x110c2, 0x110c2), // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
+ intArrayOf(0x11100, 0x11102), // Chakma Sign Candrabindu ..Chakma Sign Visarga
+ intArrayOf(0x11127, 0x1112b), // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
+ intArrayOf(0x1112d, 0x11134), // Chakma Vowel Sign Ai ..Chakma Maayyaa
+ intArrayOf(0x11173, 0x11173), // Mahajani Sign Nukta ..Mahajani Sign Nukta
+ intArrayOf(0x11180, 0x11181), // Sharada Sign Candrabindu..Sharada Sign Anusvara
+ intArrayOf(0x111b6, 0x111be), // Sharada Vowel Sign U ..Sharada Vowel Sign O
+ intArrayOf(0x111c9, 0x111cc), // Sharada Sandhi Mark ..Sharada Extra Short Vowe
+ intArrayOf(0x111cf, 0x111cf), // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
+ intArrayOf(0x1122f, 0x11231), // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
+ intArrayOf(0x11234, 0x11234), // Khojki Sign Anusvara ..Khojki Sign Anusvara
+ intArrayOf(0x11236, 0x11237), // Khojki Sign Nukta ..Khojki Sign Shadda
+ intArrayOf(0x1123e, 0x1123e), // Khojki Sign Sukun ..Khojki Sign Sukun
+ intArrayOf(0x11241, 0x11241), // (nil) ..(nil)
+ intArrayOf(0x112df, 0x112df), // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
+ intArrayOf(0x112e3, 0x112ea), // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
+ intArrayOf(0x11300, 0x11301), // Grantha Sign Combining A..Grantha Sign Candrabindu
+ intArrayOf(0x1133b, 0x1133c), // Combining Bindu Below ..Grantha Sign Nukta
+ intArrayOf(0x11340, 0x11340), // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
+ intArrayOf(0x11366, 0x1136c), // Combining Grantha Digit ..Combining Grantha Digit
+ intArrayOf(0x11370, 0x11374), // Combining Grantha Letter..Combining Grantha Letter
+ intArrayOf(0x11438, 0x1143f), // Newa Vowel Sign U ..Newa Vowel Sign Ai
+ intArrayOf(0x11442, 0x11444), // Newa Sign Virama ..Newa Sign Anusvara
+ intArrayOf(0x11446, 0x11446), // Newa Sign Nukta ..Newa Sign Nukta
+ intArrayOf(0x1145e, 0x1145e), // Newa Sandhi Mark ..Newa Sandhi Mark
+ intArrayOf(0x114b3, 0x114b8), // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
+ intArrayOf(0x114ba, 0x114ba), // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
+ intArrayOf(0x114bf, 0x114c0), // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
+ intArrayOf(0x114c2, 0x114c3), // Tirhuta Sign Virama ..Tirhuta Sign Nukta
+ intArrayOf(0x115b2, 0x115b5), // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
+ intArrayOf(0x115bc, 0x115bd), // Siddham Sign Candrabindu..Siddham Sign Anusvara
+ intArrayOf(0x115bf, 0x115c0), // Siddham Sign Virama ..Siddham Sign Nukta
+ intArrayOf(0x115dc, 0x115dd), // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
+ intArrayOf(0x11633, 0x1163a), // Modi Vowel Sign U ..Modi Vowel Sign Ai
+ intArrayOf(0x1163d, 0x1163d), // Modi Sign Anusvara ..Modi Sign Anusvara
+ intArrayOf(0x1163f, 0x11640), // Modi Sign Virama ..Modi Sign Ardhacandra
+ intArrayOf(0x116ab, 0x116ab), // Takri Sign Anusvara ..Takri Sign Anusvara
+ intArrayOf(0x116ad, 0x116ad), // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
+ intArrayOf(0x116b0, 0x116b5), // Takri Vowel Sign U ..Takri Vowel Sign Au
+ intArrayOf(0x116b7, 0x116b7), // Takri Sign Nukta ..Takri Sign Nukta
+ intArrayOf(0x1171d, 0x1171f), // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
+ intArrayOf(0x11722, 0x11725), // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
+ intArrayOf(0x11727, 0x1172b), // Ahom Vowel Sign Aw ..Ahom Sign Killer
+ intArrayOf(0x1182f, 0x11837), // Dogra Vowel Sign U ..Dogra Sign Anusvara
+ intArrayOf(0x11839, 0x1183a), // Dogra Sign Virama ..Dogra Sign Nukta
+ intArrayOf(0x1193b, 0x1193c), // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
+ intArrayOf(0x1193e, 0x1193e), // Dives Akuru Virama ..Dives Akuru Virama
+ intArrayOf(0x11943, 0x11943), // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
+ intArrayOf(0x119d4, 0x119d7), // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
+ intArrayOf(0x119da, 0x119db), // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
+ intArrayOf(0x119e0, 0x119e0), // Nandinagari Sign Virama ..Nandinagari Sign Virama
+ intArrayOf(0x11a01, 0x11a0a), // Zanabazar Square Vowel S..Zanabazar Square Vowel L
+ intArrayOf(0x11a33, 0x11a38), // Zanabazar Square Final C..Zanabazar Square Sign An
+ intArrayOf(0x11a3b, 0x11a3e), // Zanabazar Square Cluster..Zanabazar Square Cluster
+ intArrayOf(0x11a47, 0x11a47), // Zanabazar Square Subjoin..Zanabazar Square Subjoin
+ intArrayOf(0x11a51, 0x11a56), // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
+ intArrayOf(0x11a59, 0x11a5b), // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
+ intArrayOf(0x11a8a, 0x11a96), // Soyombo Final Consonant ..Soyombo Sign Anusvara
+ intArrayOf(0x11a98, 0x11a99), // Soyombo Gemination Mark ..Soyombo Subjoiner
+ intArrayOf(0x11c30, 0x11c36), // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
+ intArrayOf(0x11c38, 0x11c3d), // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
+ intArrayOf(0x11c3f, 0x11c3f), // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
+ intArrayOf(0x11c92, 0x11ca7), // Marchen Subjoined Letter..Marchen Subjoined Letter
+ intArrayOf(0x11caa, 0x11cb0), // Marchen Subjoined Letter..Marchen Vowel Sign Aa
+ intArrayOf(0x11cb2, 0x11cb3), // Marchen Vowel Sign U ..Marchen Vowel Sign E
+ intArrayOf(0x11cb5, 0x11cb6), // Marchen Sign Anusvara ..Marchen Sign Candrabindu
+ intArrayOf(0x11d31, 0x11d36), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ intArrayOf(0x11d3a, 0x11d3a), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ intArrayOf(0x11d3c, 0x11d3d), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ intArrayOf(0x11d3f, 0x11d45), // Masaram Gondi Vowel Sign..Masaram Gondi Virama
+ intArrayOf(0x11d47, 0x11d47), // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
+ intArrayOf(0x11d90, 0x11d91), // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
+ intArrayOf(0x11d95, 0x11d95), // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
+ intArrayOf(0x11d97, 0x11d97), // Gunjala Gondi Virama ..Gunjala Gondi Virama
+ intArrayOf(0x11ef3, 0x11ef4), // Makasar Vowel Sign I ..Makasar Vowel Sign U
+ intArrayOf(0x11f00, 0x11f01), // (nil) ..(nil)
+ intArrayOf(0x11f36, 0x11f3a), // (nil) ..(nil)
+ intArrayOf(0x11f40, 0x11f40), // (nil) ..(nil)
+ intArrayOf(0x11f42, 0x11f42), // (nil) ..(nil)
+ intArrayOf(0x13440, 0x13440), // (nil) ..(nil)
+ intArrayOf(0x13447, 0x13455), // (nil) ..(nil)
+ intArrayOf(0x16af0, 0x16af4), // Bassa Vah Combining High..Bassa Vah Combining High
+ intArrayOf(0x16b30, 0x16b36), // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
+ intArrayOf(0x16f4f, 0x16f4f), // Miao Sign Consonant Modi..Miao Sign Consonant Modi
+ intArrayOf(0x16f8f, 0x16f92), // Miao Tone Right ..Miao Tone Below
+ intArrayOf(0x16fe4, 0x16fe4), // Khitan Small Script Fill..Khitan Small Script Fill
+ intArrayOf(0x1bc9d, 0x1bc9e), // Duployan Thick Letter Se..Duployan Double Mark
+ intArrayOf(0x1cf00, 0x1cf2d), // Znamenny Combining Mark ..Znamenny Combining Mark
+ intArrayOf(0x1cf30, 0x1cf46), // Znamenny Combining Tonal..Znamenny Priznak Modifie
+ intArrayOf(0x1d167, 0x1d169), // Musical Symbol Combining..Musical Symbol Combining
+ intArrayOf(0x1d17b, 0x1d182), // Musical Symbol Combining..Musical Symbol Combining
+ intArrayOf(0x1d185, 0x1d18b), // Musical Symbol Combining..Musical Symbol Combining
+ intArrayOf(0x1d1aa, 0x1d1ad), // Musical Symbol Combining..Musical Symbol Combining
+ intArrayOf(0x1d242, 0x1d244), // Combining Greek Musical ..Combining Greek Musical
+ intArrayOf(0x1da00, 0x1da36), // Signwriting Head Rim ..Signwriting Air Sucking
+ intArrayOf(0x1da3b, 0x1da6c), // Signwriting Mouth Closed..Signwriting Excitement
+ intArrayOf(0x1da75, 0x1da75), // Signwriting Upper Body T..Signwriting Upper Body T
+ intArrayOf(0x1da84, 0x1da84), // Signwriting Location Hea..Signwriting Location Hea
+ intArrayOf(0x1da9b, 0x1da9f), // Signwriting Fill Modifie..Signwriting Fill Modifie
+ intArrayOf(0x1daa1, 0x1daaf), // Signwriting Rotation Mod..Signwriting Rotation Mod
+ intArrayOf(0x1e000, 0x1e006), // Combining Glagolitic Let..Combining Glagolitic Let
+ intArrayOf(0x1e008, 0x1e018), // Combining Glagolitic Let..Combining Glagolitic Let
+ intArrayOf(0x1e01b, 0x1e021), // Combining Glagolitic Let..Combining Glagolitic Let
+ intArrayOf(0x1e023, 0x1e024), // Combining Glagolitic Let..Combining Glagolitic Let
+ intArrayOf(0x1e026, 0x1e02a), // Combining Glagolitic Let..Combining Glagolitic Let
+ intArrayOf(0x1e08f, 0x1e08f), // (nil) ..(nil)
+ intArrayOf(0x1e130, 0x1e136), // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
+ intArrayOf(0x1e2ae, 0x1e2ae), // Toto Sign Rising Tone ..Toto Sign Rising Tone
+ intArrayOf(0x1e2ec, 0x1e2ef), // Wancho Tone Tup ..Wancho Tone Koini
+ intArrayOf(0x1e4ec, 0x1e4ef), // (nil) ..(nil)
+ intArrayOf(0x1e8d0, 0x1e8d6), // Mende Kikakui Combining ..Mende Kikakui Combining
+ intArrayOf(0x1e944, 0x1e94a), // Adlam Alif Lengthener ..Adlam Nukta
+ intArrayOf(0xe0100, 0xe01ef), // Variation Selector-17 ..Variation Selector-256
+
+ )
+
+ // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
+ // from https://github.com/jquast/wcwidth/pull/64
+ // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
+ private val WIDE_EASTASIAN = arrayOf(
+
+ intArrayOf(0x01100, 0x0115f), // Hangul Choseong Kiyeok ..Hangul Choseong Filler
+ intArrayOf(0x0231a, 0x0231b), // Watch ..Hourglass
+ intArrayOf(0x02329, 0x0232a), // Left-pointing Angle Brac..Right-pointing Angle Bra
+ intArrayOf(0x023e9, 0x023ec), // Black Right-pointing Dou..Black Down-pointing Doub
+ intArrayOf(0x023f0, 0x023f0), // Alarm Clock ..Alarm Clock
+ intArrayOf(0x023f3, 0x023f3), // Hourglass With Flowing S..Hourglass With Flowing S
+ intArrayOf(0x025fd, 0x025fe), // White Medium Small Squar..Black Medium Small Squar
+ intArrayOf(0x02614, 0x02615), // Umbrella With Rain Drops..Hot Beverage
+ intArrayOf(0x02648, 0x02653), // Aries ..Pisces
+ intArrayOf(0x0267f, 0x0267f), // Wheelchair Symbol ..Wheelchair Symbol
+ intArrayOf(0x02693, 0x02693), // Anchor ..Anchor
+ intArrayOf(0x026a1, 0x026a1), // High Voltage Sign ..High Voltage Sign
+ intArrayOf(0x026aa, 0x026ab), // Medium White Circle ..Medium Black Circle
+ intArrayOf(0x026bd, 0x026be), // Soccer Ball ..Baseball
+ intArrayOf(0x026c4, 0x026c5), // Snowman Without Snow ..Sun Behind Cloud
+ intArrayOf(0x026ce, 0x026ce), // Ophiuchus ..Ophiuchus
+ intArrayOf(0x026d4, 0x026d4), // No Entry ..No Entry
+ intArrayOf(0x026ea, 0x026ea), // Church ..Church
+ intArrayOf(0x026f2, 0x026f3), // Fountain ..Flag In Hole
+ intArrayOf(0x026f5, 0x026f5), // Sailboat ..Sailboat
+ intArrayOf(0x026fa, 0x026fa), // Tent ..Tent
+ intArrayOf(0x026fd, 0x026fd), // Fuel Pump ..Fuel Pump
+ intArrayOf(0x02705, 0x02705), // White Heavy Check Mark ..White Heavy Check Mark
+ intArrayOf(0x0270a, 0x0270b), // Raised Fist ..Raised Hand
+ intArrayOf(0x02728, 0x02728), // Sparkles ..Sparkles
+ intArrayOf(0x0274c, 0x0274c), // Cross Mark ..Cross Mark
+ intArrayOf(0x0274e, 0x0274e), // Negative Squared Cross M..Negative Squared Cross M
+ intArrayOf(0x02753, 0x02755), // Black Question Mark Orna..White Exclamation Mark O
+ intArrayOf(0x02757, 0x02757), // Heavy Exclamation Mark S..Heavy Exclamation Mark S
+ intArrayOf(0x02795, 0x02797), // Heavy Plus Sign ..Heavy Division Sign
+ intArrayOf(0x027b0, 0x027b0), // Curly Loop ..Curly Loop
+ intArrayOf(0x027bf, 0x027bf), // Double Curly Loop ..Double Curly Loop
+ intArrayOf(0x02b1b, 0x02b1c), // Black Large Square ..White Large Square
+ intArrayOf(0x02b50, 0x02b50), // White Medium Star ..White Medium Star
+ intArrayOf(0x02b55, 0x02b55), // Heavy Large Circle ..Heavy Large Circle
+ intArrayOf(0x02e80, 0x02e99), // Cjk Radical Repeat ..Cjk Radical Rap
+ intArrayOf(0x02e9b, 0x02ef3), // Cjk Radical Choke ..Cjk Radical C-simplified
+ intArrayOf(0x02f00, 0x02fd5), // Kangxi Radical One ..Kangxi Radical Flute
+ intArrayOf(0x02ff0, 0x02ffb), // Ideographic Description ..Ideographic Description
+ intArrayOf(0x03000, 0x0303e), // Ideographic Space ..Ideographic Variation In
+ intArrayOf(0x03041, 0x03096), // Hiragana Letter Small A ..Hiragana Letter Small Ke
+ intArrayOf(0x03099, 0x030ff), // Combining Katakana-hirag..Katakana Digraph Koto
+ intArrayOf(0x03105, 0x0312f), // Bopomofo Letter B ..Bopomofo Letter Nn
+ intArrayOf(0x03131, 0x0318e), // Hangul Letter Kiyeok ..Hangul Letter Araeae
+ intArrayOf(0x03190, 0x031e3), // Ideographic Annotation L..Cjk Stroke Q
+ intArrayOf(0x031f0, 0x0321e), // Katakana Letter Small Ku..Parenthesized Korean Cha
+ intArrayOf(0x03220, 0x03247), // Parenthesized Ideograph ..Circled Ideograph Koto
+ intArrayOf(0x03250, 0x04dbf), // Partnership Sign ..Cjk Unified Ideograph-4d
+ intArrayOf(0x04e00, 0x0a48c), // Cjk Unified Ideograph-4e..Yi Syllable Yyr
+ intArrayOf(0x0a490, 0x0a4c6), // Yi Radical Qot ..Yi Radical Ke
+ intArrayOf(0x0a960, 0x0a97c), // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
+ intArrayOf(0x0ac00, 0x0d7a3), // Hangul Syllable Ga ..Hangul Syllable Hih
+ intArrayOf(0x0f900, 0x0faff), // Cjk Compatibility Ideogr..(nil)
+ intArrayOf(0x0fe10, 0x0fe19), // Presentation Form For Ve..Presentation Form For Ve
+ intArrayOf(0x0fe30, 0x0fe52), // Presentation Form For Ve..Small Full Stop
+ intArrayOf(0x0fe54, 0x0fe66), // Small Semicolon ..Small Equals Sign
+ intArrayOf(0x0fe68, 0x0fe6b), // Small Reverse Solidus ..Small Commercial At
+ intArrayOf(0x0ff01, 0x0ff60), // Fullwidth Exclamation Ma..Fullwidth Right White Pa
+ intArrayOf(0x0ffe0, 0x0ffe6), // Fullwidth Cent Sign ..Fullwidth Won Sign
+ intArrayOf(0x16fe0, 0x16fe4), // Tangut Iteration Mark ..Khitan Small Script Fill
+ intArrayOf(0x16ff0, 0x16ff1), // Vietnamese Alternate Rea..Vietnamese Alternate Rea
+ intArrayOf(0x17000, 0x187f7), // (nil) ..(nil)
+ intArrayOf(0x18800, 0x18cd5), // Tangut Component-001 ..Khitan Small Script Char
+ intArrayOf(0x18d00, 0x18d08), // (nil) ..(nil)
+ intArrayOf(0x1aff0, 0x1aff3), // Katakana Letter Minnan T..Katakana Letter Minnan T
+ intArrayOf(0x1aff5, 0x1affb), // Katakana Letter Minnan T..Katakana Letter Minnan N
+ intArrayOf(0x1affd, 0x1affe), // Katakana Letter Minnan N..Katakana Letter Minnan N
+ intArrayOf(0x1b000, 0x1b122), // Katakana Letter Archaic ..Katakana Letter Archaic
+ intArrayOf(0x1b132, 0x1b132), // (nil) ..(nil)
+ intArrayOf(0x1b150, 0x1b152), // Hiragana Letter Small Wi..Hiragana Letter Small Wo
+ intArrayOf(0x1b155, 0x1b155), // (nil) ..(nil)
+ intArrayOf(0x1b164, 0x1b167), // Katakana Letter Small Wi..Katakana Letter Small N
+ intArrayOf(0x1b170, 0x1b2fb), // Nushu Character-1b170 ..Nushu Character-1b2fb
+ intArrayOf(0x1f004, 0x1f004), // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
+ intArrayOf(0x1f0cf, 0x1f0cf), // Playing Card Black Joker..Playing Card Black Joker
+ intArrayOf(0x1f18e, 0x1f18e), // Negative Squared Ab ..Negative Squared Ab
+ intArrayOf(0x1f191, 0x1f19a), // Squared Cl ..Squared Vs
+ intArrayOf(0x1f200, 0x1f202), // Square Hiragana Hoka ..Squared Katakana Sa
+ intArrayOf(0x1f210, 0x1f23b), // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
+ intArrayOf(0x1f240, 0x1f248), // Tortoise Shell Bracketed..Tortoise Shell Bracketed
+ intArrayOf(0x1f250, 0x1f251), // Circled Ideograph Advant..Circled Ideograph Accept
+ intArrayOf(0x1f260, 0x1f265), // Rounded Symbol For Fu ..Rounded Symbol For Cai
+ intArrayOf(0x1f300, 0x1f320), // Cyclone ..Shooting Star
+ intArrayOf(0x1f32d, 0x1f335), // Hot Dog ..Cactus
+ intArrayOf(0x1f337, 0x1f37c), // Tulip ..Baby Bottle
+ intArrayOf(0x1f37e, 0x1f393), // Bottle With Popping Cork..Graduation Cap
+ intArrayOf(0x1f3a0, 0x1f3ca), // Carousel Horse ..Swimmer
+ intArrayOf(0x1f3cf, 0x1f3d3), // Cricket Bat And Ball ..Table Tennis Paddle And
+ intArrayOf(0x1f3e0, 0x1f3f0), // House Building ..European Castle
+ intArrayOf(0x1f3f4, 0x1f3f4), // Waving Black Flag ..Waving Black Flag
+ intArrayOf(0x1f3f8, 0x1f43e), // Badminton Racquet And Sh..Paw Prints
+ intArrayOf(0x1f440, 0x1f440), // Eyes ..Eyes
+ intArrayOf(0x1f442, 0x1f4fc), // Ear ..Videocassette
+ intArrayOf(0x1f4ff, 0x1f53d), // Prayer Beads ..Down-pointing Small Red
+ intArrayOf(0x1f54b, 0x1f54e), // Kaaba ..Menorah With Nine Branch
+ intArrayOf(0x1f550, 0x1f567), // Clock Face One Oclock ..Clock Face Twelve-thirty
+ intArrayOf(0x1f57a, 0x1f57a), // Man Dancing ..Man Dancing
+ intArrayOf(0x1f595, 0x1f596), // Reversed Hand With Middl..Raised Hand With Part Be
+ intArrayOf(0x1f5a4, 0x1f5a4), // Black Heart ..Black Heart
+ intArrayOf(0x1f5fb, 0x1f64f), // Mount Fuji ..Person With Folded Hands
+ intArrayOf(0x1f680, 0x1f6c5), // Rocket ..Left Luggage
+ intArrayOf(0x1f6cc, 0x1f6cc), // Sleeping Accommodation ..Sleeping Accommodation
+ intArrayOf(0x1f6d0, 0x1f6d2), // Place Of Worship ..Shopping Trolley
+ intArrayOf(0x1f6d5, 0x1f6d7), // Hindu Temple ..Elevator
+ intArrayOf(0x1f6dc, 0x1f6df), // (nil) ..Ring Buoy
+ intArrayOf(0x1f6eb, 0x1f6ec), // Airplane Departure ..Airplane Arriving
+ intArrayOf(0x1f6f4, 0x1f6fc), // Scooter ..Roller Skate
+ intArrayOf(0x1f7e0, 0x1f7eb), // Large Orange Circle ..Large Brown Square
+ intArrayOf(0x1f7f0, 0x1f7f0), // Heavy Equals Sign ..Heavy Equals Sign
+ intArrayOf(0x1f90c, 0x1f93a), // Pinched Fingers ..Fencer
+ intArrayOf(0x1f93c, 0x1f945), // Wrestlers ..Goal Net
+ intArrayOf(0x1f947, 0x1f9ff), // First Place Medal ..Nazar Amulet
+ intArrayOf(0x1fa70, 0x1fa7c), // Ballet Shoes ..Crutch
+ intArrayOf(0x1fa80, 0x1fa88), // Yo-yo ..(nil)
+ intArrayOf(0x1fa90, 0x1fabd), // Ringed Planet ..(nil)
+ intArrayOf(0x1fabf, 0x1fac5), // (nil) ..Person With Crown
+ intArrayOf(0x1face, 0x1fadb), // (nil) ..(nil)
+ intArrayOf(0x1fae0, 0x1fae8), // Melting Face ..(nil)
+ intArrayOf(0x1faf0, 0x1faf8), // Hand With Index Finger A..(nil)
+ intArrayOf(0x20000, 0x2fffd), // Cjk Unified Ideograph-20..(nil)
+ intArrayOf(0x30000, 0x3fffd), // Cjk Unified Ideograph-30..(nil)
+
+ )
+
+ private fun intable(table: Array, c: Int): Boolean {
+ // First quick check for Latin1 etc. characters.
+ if (c < table[0][0]) return false
+
+ // Binary search in table.
+ var bot = 0
+ var top = table.size - 1
+ while (top >= bot) {
+ val mid = (bot + top) / 2
+ if (table[mid][1] < c) {
+ bot = mid + 1
+ } else if (table[mid][0] > c) {
+ top = mid - 1
+ } else {
+ return true
+ }
+ }
+ return false
+ }
+
+ /** Return the terminal display width of a code point: 0, 1 or 2. */
+ @JvmStatic
+ fun width(ucs: Int): Int {
+ if (ucs == 0 ||
+ ucs == 0x034F ||
+ (ucs in 0x200B..0x200F) ||
+ ucs == 0x2028 ||
+ ucs == 0x2029 ||
+ (ucs in 0x202A..0x202E) ||
+ (ucs in 0x2060..0x2063)) {
+ return 0
+ }
+
+ // C0/C1 control characters
+ // Termux change: Return 0 instead of -1.
+ if (ucs < 32 || (ucs in 0x07F until 0x0A0)) return 0
+
+ // combining characters with zero width
+ if (intable(ZERO_WIDTH, ucs)) return 0
+
+ return if (intable(WIDE_EASTASIAN, ucs)) 2 else 1
+ }
+
+ /** The width at an index position in a java char array. */
+ @JvmStatic
+ fun width(chars: CharArray, index: Int): Int {
+ val c = chars[index]
+ return if (Character.isHighSurrogate(c)) width(Character.toCodePoint(c, chars[index + 1])) else width(c.code)
+ }
+
+ /**
+ * The zero width characters count like combining characters in the `chars` array from start
+ * index to end index (exclusive).
+ */
+ @JvmStatic
+ fun zeroWidthCharsCount(chars: CharArray, start: Int, end: Int): Int {
+ if (start < 0 || start >= chars.size) return 0
+
+ var count = 0
+ var i = start
+ while (i < end && i < chars.size) {
+ if (Character.isHighSurrogate(chars[i])) {
+ if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) {
+ count++
+ }
+ i += 2
+ } else {
+ if (width(chars[i].code) <= 0) {
+ count++
+ }
+ i++
+ }
+ }
+ return count
+ }
+}
diff --git a/app/src/main/java/com/termux/view/GestureAndScaleRecognizer.kt b/app/src/main/java/com/termux/view/GestureAndScaleRecognizer.kt
new file mode 100644
index 0000000..dac34aa
--- /dev/null
+++ b/app/src/main/java/com/termux/view/GestureAndScaleRecognizer.kt
@@ -0,0 +1,90 @@
+package com.termux.view
+
+import android.content.Context
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+
+/** A combination of [GestureDetector] and [ScaleGestureDetector]. */
+internal class GestureAndScaleRecognizer(context: Context, val mListener: Listener) {
+
+ interface Listener {
+ fun onSingleTapUp(e: MotionEvent): Boolean
+ fun onDoubleTap(e: MotionEvent): Boolean
+ fun onScroll(e2: MotionEvent, dx: Float, dy: Float): Boolean
+ fun onFling(e: MotionEvent, velocityX: Float, velocityY: Float): Boolean
+ fun onScale(focusX: Float, focusY: Float, scale: Float): Boolean
+ fun onDown(x: Float, y: Float): Boolean
+ fun onUp(e: MotionEvent): Boolean
+ fun onLongPress(e: MotionEvent)
+ }
+
+ private val mGestureDetector: GestureDetector
+ private val mScaleDetector: ScaleGestureDetector
+ var isAfterLongPress = false
+
+ init {
+ mGestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+ override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean {
+ return mListener.onScroll(e2, dx, dy)
+ }
+
+ override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
+ return mListener.onFling(e2, velocityX, velocityY)
+ }
+
+ override fun onDown(e: MotionEvent): Boolean {
+ return mListener.onDown(e.x, e.y)
+ }
+
+ override fun onLongPress(e: MotionEvent) {
+ mListener.onLongPress(e)
+ isAfterLongPress = true
+ }
+ }, null, true /* ignoreMultitouch */)
+
+ mGestureDetector.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
+ override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+ return mListener.onSingleTapUp(e)
+ }
+
+ override fun onDoubleTap(e: MotionEvent): Boolean {
+ return mListener.onDoubleTap(e)
+ }
+
+ override fun onDoubleTapEvent(e: MotionEvent): Boolean {
+ return true
+ }
+ })
+
+ mScaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ return mListener.onScale(detector.focusX, detector.focusY, detector.scaleFactor)
+ }
+ })
+ mScaleDetector.isQuickScaleEnabled = false
+ }
+
+ fun onTouchEvent(event: MotionEvent) {
+ mGestureDetector.onTouchEvent(event)
+ mScaleDetector.onTouchEvent(event)
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> isAfterLongPress = false
+ MotionEvent.ACTION_UP -> {
+ if (!isAfterLongPress) {
+ // This behaviour is desired when in e.g. vim with mouse events, where we do not
+ // want to move the cursor when lifting finger after a long press.
+ mListener.onUp(event)
+ }
+ }
+ }
+ }
+
+ fun isInProgress(): Boolean {
+ return mScaleDetector.isInProgress
+ }
+}
diff --git a/app/src/main/java/com/termux/view/TerminalRenderer.kt b/app/src/main/java/com/termux/view/TerminalRenderer.kt
new file mode 100644
index 0000000..af732f4
--- /dev/null
+++ b/app/src/main/java/com/termux/view/TerminalRenderer.kt
@@ -0,0 +1,265 @@
+package com.termux.view
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.Typeface
+import com.termux.terminal.TerminalBuffer
+import com.termux.terminal.TerminalEmulator
+import com.termux.terminal.TerminalRow
+import com.termux.terminal.TextStyle
+import com.termux.terminal.WcWidth
+
+/**
+ * Renderer of a [TerminalEmulator] into a [Canvas].
+ *
+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes.
+ */
+class TerminalRenderer(
+ @JvmField val mTextSize: Int,
+ @JvmField val mTypeface: Typeface
+) {
+ private val mTextPaint = Paint()
+
+ /** The width of a single mono spaced character obtained by [Paint.measureText] on a single 'X'. */
+ @JvmField
+ val mFontWidth: Float
+
+ /** The [Paint.getFontSpacing]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ @JvmField
+ val mFontLineSpacing: Int
+
+ /** The [Paint.ascent]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ private val mFontAscent: Int
+
+ /** The [mFontLineSpacing] + [mFontAscent]. */
+ @JvmField
+ val mFontLineSpacingAndAscent: Int
+
+ private val asciiMeasures = FloatArray(127)
+
+ init {
+ mTextPaint.typeface = mTypeface
+ mTextPaint.isAntiAlias = true
+ mTextPaint.textSize = mTextSize.toFloat()
+
+ mFontLineSpacing = Math.ceil(mTextPaint.fontSpacing.toDouble()).toInt()
+ mFontAscent = Math.ceil(mTextPaint.ascent().toDouble()).toInt()
+ mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent
+ mFontWidth = mTextPaint.measureText("X")
+
+ val sb = StringBuilder(" ")
+ for (i in asciiMeasures.indices) {
+ sb.setCharAt(0, i.toChar())
+ asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1)
+ }
+ }
+
+ /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */
+ fun render(
+ mEmulator: TerminalEmulator, canvas: Canvas, topRow: Int,
+ selectionY1: Int, selectionY2: Int, selectionX1: Int, selectionX2: Int
+ ) {
+ val reverseVideo = mEmulator.isReverseVideo()
+ val endRow = topRow + mEmulator.mRows
+ val columns = mEmulator.mColumns
+ val cursorCol = mEmulator.getCursorCol()
+ val cursorRow = mEmulator.getCursorRow()
+ val cursorVisible = mEmulator.shouldCursorBeVisible()
+ val screen = mEmulator.getScreen()
+ val palette = mEmulator.mColors.mCurrentColors
+ val cursorShape = mEmulator.getCursorStyle()
+
+ if (reverseVideo)
+ canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC)
+
+ var heightOffset = mFontLineSpacingAndAscent.toFloat()
+ for (row in topRow until endRow) {
+ heightOffset += mFontLineSpacing
+
+ val cursorX = if (row == cursorRow && cursorVisible) cursorCol else -1
+ var selx1 = -1
+ var selx2 = -1
+ if (row >= selectionY1 && row <= selectionY2) {
+ if (row == selectionY1) selx1 = selectionX1
+ selx2 = if (row == selectionY2) selectionX2 else mEmulator.mColumns
+ }
+
+ val lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
+ val line = lineObject.mText
+ val charsUsedInLine = lineObject.spaceUsed
+
+ var lastRunStyle: Long = 0
+ var lastRunInsideCursor = false
+ var lastRunInsideSelection = false
+ var lastRunStartColumn = -1
+ var lastRunStartIndex = 0
+ var lastRunFontWidthMismatch = false
+ var currentCharIndex = 0
+ var measuredWidthForRun = 0f
+
+ var column = 0
+ while (column < columns) {
+ val charAtIndex = line[currentCharIndex]
+ val charIsHighsurrogate = Character.isHighSurrogate(charAtIndex)
+ val charsForCodePoint = if (charIsHighsurrogate) 2 else 1
+ val codePoint = if (charIsHighsurrogate) Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) else charAtIndex.code
+ val codePointWcWidth = WcWidth.width(codePoint)
+ val insideCursor = cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)
+ val insideSelection = column >= selx1 && column <= selx2
+ val 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.
+ val measuredCodePointWidth = if (codePoint < asciiMeasures.size) asciiMeasures[codePoint] else mTextPaint.measureText(
+ line as CharArray,
+ currentCharIndex, charsForCodePoint
+ )
+ val 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 {
+ val columnWidthSinceLastRun = column - lastRunStartColumn
+ val charsSinceLastRun = currentCharIndex - lastRunStartIndex
+ val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
+ var 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
+ )
+ }
+ measuredWidthForRun = 0f
+ lastRunStyle = style
+ lastRunInsideCursor = insideCursor
+ lastRunInsideSelection = insideSelection
+ lastRunStartColumn = column
+ lastRunStartIndex = currentCharIndex
+ lastRunFontWidthMismatch = fontWidthMismatch
+ }
+ measuredWidthForRun += measuredCodePointWidth
+ column += codePointWcWidth
+ currentCharIndex += charsForCodePoint
+ while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex).compareTo(0) <= 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 += if (Character.isHighSurrogate(line[currentCharIndex])) 2 else 1
+ }
+ }
+
+ val columnWidthSinceLastRun = columns - lastRunStartColumn
+ val charsSinceLastRun = currentCharIndex - lastRunStartIndex
+ val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
+ var 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
+ )
+ }
+ }
+
+ @Suppress("NewApi")
+ private fun drawTextRun(
+ canvas: Canvas, text: CharArray, palette: IntArray, y: Float, startColumn: Int, runWidthColumns: Int,
+ startCharIndex: Int, runWidthChars: Int, mes: Float, cursor: Int, cursorStyle: Int,
+ textStyle: Long, reverseVideo: Boolean
+ ) {
+ var foreColor = TextStyle.decodeForeColor(textStyle)
+ val effect = TextStyle.decodeEffect(textStyle)
+ var backColor = TextStyle.decodeBackColor(textStyle)
+ val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
+ val underline = (effect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0
+ val italic = (effect and TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0
+ val strikeThrough = (effect and TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0
+ val dim = (effect and TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0
+
+ if ((foreColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
+ // Let bold have bright colors if applicable (one of the first 8):
+ if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8
+ foreColor = palette[foreColor]
+ }
+
+ if ((backColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
+ backColor = palette[backColor]
+ }
+
+ // Reverse video here if _one and only one_ of the reverse flags are set:
+ val reverseVideoHere = reverseVideo xor ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0)
+ if (reverseVideoHere) {
+ val tmp = foreColor
+ foreColor = backColor
+ backColor = tmp
+ }
+
+ var left = startColumn * mFontWidth
+ var right = left + runWidthColumns * mFontWidth
+
+ var mesScaled = mes / mFontWidth
+ var savedMatrix = false
+ if (Math.abs(mesScaled - runWidthColumns) > 0.01) {
+ canvas.save()
+ canvas.scale(runWidthColumns / mesScaled, 1f)
+ left *= mesScaled / runWidthColumns
+ right *= mesScaled / runWidthColumns
+ savedMatrix = true
+ }
+
+ if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
+ // Only draw non-default background.
+ mTextPaint.color = backColor
+ canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint)
+ }
+
+ if (cursor != 0) {
+ mTextPaint.color = cursor
+ var cursorHeight = (mFontLineSpacingAndAscent - mFontAscent).toFloat()
+ if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4f
+ else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4f
+ canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint)
+ }
+
+ if ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
+ var actualForeColor = foreColor
+ if (dim) {
+ var red = 0xFF and (foreColor shr 16)
+ var green = 0xFF and (foreColor shr 8)
+ var blue = 0xFF and 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
+ actualForeColor = 0xFF000000.toInt() + (red shl 16) + (green shl 8) + blue
+ }
+
+ mTextPaint.isFakeBoldText = bold
+ mTextPaint.isUnderlineText = underline
+ mTextPaint.textSkewX = if (italic) -0.35f else 0f
+ mTextPaint.isStrikeThruText = strikeThrough
+ mTextPaint.color = actualForeColor
+
+ // The text alignment is the default Paint.Align.LEFT.
+ canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint)
+ }
+
+ if (savedMatrix) canvas.restore()
+ }
+
+ fun getFontWidth(): Float {
+ return mFontWidth
+ }
+
+ fun getFontLineSpacing(): Int {
+ return mFontLineSpacing
+ }
+}
diff --git a/app/src/main/java/com/termux/view/TerminalView.kt b/app/src/main/java/com/termux/view/TerminalView.kt
new file mode 100644
index 0000000..452d9d9
--- /dev/null
+++ b/app/src/main/java/com/termux/view/TerminalView.kt
@@ -0,0 +1,1222 @@
+package com.termux.view
+
+import android.annotation.SuppressLint
+import android.annotation.TargetApi
+import android.app.Activity
+import android.content.ClipboardManager
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Typeface
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.text.InputType
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.view.ActionMode
+import android.view.HapticFeedbackConstants
+import android.view.InputDevice
+import android.view.KeyCharacterMap
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewTreeObserver
+import android.view.accessibility.AccessibilityManager
+import android.view.autofill.AutofillManager
+import android.view.autofill.AutofillValue
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.widget.Scroller
+
+import androidx.annotation.RequiresApi
+
+import com.termux.terminal.KeyHandler
+import com.termux.terminal.TerminalEmulator
+import com.termux.terminal.TerminalSession
+import com.termux.view.textselection.TextSelectionCursorController
+
+/** View displaying and interacting with a [TerminalSession]. */
+class TerminalView(context: Context, attributes: AttributeSet?) : View(context, attributes) {
+
+ /** The currently displayed terminal session, whose emulator is [mEmulator]. */
+ @JvmField
+ var mTermSession: TerminalSession? = null
+
+ /** Our terminal emulator whose session is [mTermSession]. */
+ @JvmField
+ var mEmulator: TerminalEmulator? = null
+
+ @JvmField
+ var mRenderer: TerminalRenderer? = null
+
+ @JvmField
+ var mClient: TerminalViewClient? = null
+
+ private var mTextSelectionCursorController: TextSelectionCursorController? = null
+
+ private var mTerminalCursorBlinkerHandler: Handler? = null
+ private var mTerminalCursorBlinkerRunnable: TerminalCursorBlinkerRunnable? = null
+ private var mTerminalCursorBlinkerRate: Int = 0
+ private var mCursorInvisibleIgnoreOnce: Boolean = false
+
+ /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */
+ @JvmField
+ var mTopRow: Int = 0
+
+ @JvmField
+ var mDefaultSelectors: IntArray = intArrayOf(-1, -1, -1, -1)
+
+ @JvmField
+ var mScaleFactor: Float = 1f
+
+ internal lateinit var mGestureRecognizer: GestureAndScaleRecognizer
+
+ /** Keep track of where mouse touch event started which we report as mouse scroll. */
+ private var mMouseScrollStartX: Int = -1
+ private var mMouseScrollStartY: Int = -1
+
+ /** Keep track of the time when a touch event leading to sending mouse scroll events started. */
+ private var mMouseStartDownTime: Long = -1
+
+ lateinit var mScroller: Scroller
+
+ /** What was left in from scrolling movement. */
+ @JvmField
+ var mScrollRemainder: Float = 0f
+
+ /** If non-zero, this is the last unicode code point received if that was a combining character. */
+ @JvmField
+ var mCombiningAccent: Int = 0
+
+ /**
+ * The current AutoFill type returned for [View.getAutofillType] by [getAutofillType].
+ *
+ * The default is [AUTOFILL_TYPE_NONE] so that AutoFill UI, like toolbar above keyboard
+ * is not shown automatically, like on Activity starts/View create.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private var mAutoFillType: Int = AUTOFILL_TYPE_NONE
+
+ /**
+ * The current AutoFill type returned for [View.getImportantForAutofill] by [getImportantForAutofill].
+ *
+ * The default is [IMPORTANT_FOR_AUTOFILL_NO] so that view is not considered important for AutoFill.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private var mAutoFillImportance: Int = IMPORTANT_FOR_AUTOFILL_NO
+
+ /**
+ * The current AutoFill hints returned for [View.getAutofillHints] by [getAutofillHints].
+ */
+ private var mAutoFillHints: Array = emptyArray()
+
+ private val mAccessibilityEnabled: Boolean
+
+ init {
+ mGestureRecognizer = GestureAndScaleRecognizer(context, object : GestureAndScaleRecognizer.Listener {
+ var scrolledWithFinger = false
+
+ override fun onUp(event: MotionEvent): Boolean {
+ mScrollRemainder = 0.0f
+ if (mEmulator != null && mEmulator!!.isMouseTrackingActive() && !event.isFromSource(InputDevice.SOURCE_MOUSE) && !isSelectingText && !scrolledWithFinger) {
+ // Quick event processing when mouse tracking is active - do not wait for check of double tapping
+ // for zooming.
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true)
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false)
+ return true
+ }
+ scrolledWithFinger = false
+ return false
+ }
+
+ override fun onSingleTapUp(event: MotionEvent): Boolean {
+ if (mEmulator == null) return true
+
+ if (isSelectingText) {
+ stopTextSelectionMode()
+ return true
+ }
+ requestFocus()
+ mClient?.onSingleTapUp(event)
+ return true
+ }
+
+ override fun onScroll(e: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
+ if (mEmulator == null) return true
+ if (mEmulator!!.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ // If moving with mouse pointer while pressing button, report that instead of scroll.
+ sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true)
+ } else {
+ scrolledWithFinger = true
+ val adjustedDistanceY = distanceY + mScrollRemainder
+ val deltaRows = (adjustedDistanceY / mRenderer!!.mFontLineSpacing).toInt()
+ mScrollRemainder = adjustedDistanceY - deltaRows * mRenderer!!.mFontLineSpacing
+ doScroll(e, deltaRows)
+ }
+ return true
+ }
+
+ override fun onScale(focusX: Float, focusY: Float, scale: Float): Boolean {
+ if (mEmulator == null || isSelectingText) return true
+ mScaleFactor *= scale
+ mScaleFactor = mClient!!.onScale(mScaleFactor)
+ return true
+ }
+
+ override fun onFling(e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
+ if (mEmulator == null) return true
+ // Do not start scrolling until last fling has been taken care of:
+ if (!mScroller.isFinished) return true
+
+ val mouseTrackingAtStartOfFling = mEmulator!!.isMouseTrackingActive()
+ val SCALE = 0.25f
+ if (mouseTrackingAtStartOfFling) {
+ mScroller.fling(0, 0, 0, -(velocityY * SCALE).toInt(), 0, 0, -mEmulator!!.mRows / 2, mEmulator!!.mRows / 2)
+ } else {
+ mScroller.fling(0, mTopRow, 0, -(velocityY * SCALE).toInt(), 0, 0, -mEmulator!!.getScreen().activeTranscriptRows, 0)
+ }
+
+ post(object : Runnable {
+ private var mLastY = 0
+
+ override fun run() {
+ if (mouseTrackingAtStartOfFling != mEmulator!!.isMouseTrackingActive()) {
+ mScroller.abortAnimation()
+ return
+ }
+ if (mScroller.isFinished) return
+ val more = mScroller.computeScrollOffset()
+ val newY = mScroller.currY
+ val diff = if (mouseTrackingAtStartOfFling) (newY - mLastY) else (newY - mTopRow)
+ doScroll(e2, diff)
+ mLastY = newY
+ if (more) post(this)
+ }
+ })
+
+ return true
+ }
+
+ override fun onDown(x: Float, y: Float): Boolean {
+ return false
+ }
+
+ override fun onDoubleTap(event: MotionEvent): Boolean {
+ // Do not treat is as a single confirmed tap - it may be followed by zoom.
+ return false
+ }
+
+ override fun onLongPress(event: MotionEvent) {
+ if (mGestureRecognizer.isInProgress()) return
+ if (mClient?.onLongPress(event) == true) return
+ if (!isSelectingText) {
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ startTextSelectionMode(event)
+ }
+ }
+ })
+ mScroller = Scroller(context)
+ val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ mAccessibilityEnabled = am.isEnabled
+ }
+
+ /**
+ * @param client The [TerminalViewClient] interface implementation to allow
+ * for communication between [TerminalView] and its client.
+ */
+ fun setTerminalViewClient(client: TerminalViewClient) {
+ this.mClient = client
+ }
+
+ /**
+ * Sets whether terminal view key logging is enabled or not.
+ *
+ * @param value The boolean value that defines the state.
+ */
+ fun setIsTerminalViewKeyLoggingEnabled(value: Boolean) {
+ TERMINAL_VIEW_KEY_LOGGING_ENABLED = value
+ }
+
+ /**
+ * Attach a [TerminalSession] to this view.
+ *
+ * @param session The [TerminalSession] this view will be displaying.
+ */
+ fun attachSession(session: TerminalSession): Boolean {
+ if (session == mTermSession) return false
+ mTopRow = 0
+
+ mTermSession = session
+ mEmulator = null
+ mCombiningAccent = 0
+
+ updateSize()
+
+ // Wait with enabling the scrollbar until we have a terminal to get scroll position from.
+ isVerticalScrollBarEnabled = true
+
+ return true
+ }
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
+ // Ensure that inputType is only set if TerminalView is selected view with the keyboard and
+ // an alternate view is not selected, like an EditText.
+ if (mClient!!.isTerminalViewSelected()) {
+ if (mClient!!.shouldEnforceCharBasedInput()) {
+ // Some keyboards seems do not reset the internal state on TYPE_NULL.
+ outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ } else {
+ // Using InputType.NULL is the most correct input type and avoids issues with other hacks.
+ outAttrs.inputType = InputType.TYPE_NULL
+ }
+ } else {
+ // Corresponds to android:inputType="text"
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL
+ }
+
+ // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen
+ // keyboard on Android TV.
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN
+
+ return object : BaseInputConnection(this, true) {
+
+ override fun finishComposingText(): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient!!.logInfo(LOG_TAG, "IME: finishComposingText()")
+ super.finishComposingText()
+
+ sendTextToTerminal(editable!!)
+ editable!!.clear()
+ return true
+ }
+
+ override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient!!.logInfo(LOG_TAG, "IME: commitText(\"$text\", $newCursorPosition)")
+ }
+ super.commitText(text, newCursorPosition)
+
+ if (mEmulator == null) return true
+
+ val content = editable!!
+ sendTextToTerminal(content)
+ content.clear()
+ return true
+ }
+
+ override fun deleteSurroundingText(leftLength: Int, rightLength: Int): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient!!.logInfo(LOG_TAG, "IME: deleteSurroundingText($leftLength, $rightLength)")
+ }
+ // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1.
+ val deleteKey = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)
+ for (i in 0 until leftLength) sendKeyEvent(deleteKey)
+ return super.deleteSurroundingText(leftLength, rightLength)
+ }
+
+ fun sendTextToTerminal(text: CharSequence) {
+ stopTextSelectionMode()
+ val textLengthInChars = text.length
+ var i = 0
+ while (i < textLengthInChars) {
+ val firstChar = text[i]
+ var codePoint: Int
+ if (Character.isHighSurrogate(firstChar)) {
+ if (++i < textLengthInChars) {
+ codePoint = Character.toCodePoint(firstChar, text[i])
+ } else {
+ // At end of string, with no low surrogate following the high:
+ codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR
+ }
+ } else {
+ codePoint = firstChar.code
+ }
+
+ // Check onKeyDown() for details.
+ if (mClient!!.readShiftKey())
+ codePoint = Character.toUpperCase(codePoint)
+
+ var ctrlHeld = false
+ if (codePoint <= 31 && codePoint != 27) {
+ if (codePoint == '\n'.code) {
+ // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed,
+ // instead of a key event like most other keyboard apps. A terminal expects \r for the enter
+ // key.
+ codePoint = '\r'.code
+ }
+
+ // E.g. penti keyboard for ctrl input.
+ ctrlHeld = true
+ codePoint = when (codePoint) {
+ 31 -> '_'.code
+ 30 -> '^'.code
+ 29 -> ']'.code
+ 28 -> '\\'.code
+ else -> codePoint + 96
+ }
+ }
+
+ inputCodePoint(KEY_EVENT_SOURCE_SOFT_KEYBOARD, codePoint, ctrlHeld, false)
+ i++
+ }
+ }
+ }
+ }
+
+ override fun computeVerticalScrollRange(): Int {
+ return mEmulator?.getScreen()?.activeRows ?: 1
+ }
+
+ override fun computeVerticalScrollExtent(): Int {
+ return mEmulator?.mRows ?: 1
+ }
+
+ override fun computeVerticalScrollOffset(): Int {
+ return if (mEmulator == null) 1 else mEmulator!!.getScreen().activeRows + mTopRow - mEmulator!!.mRows
+ }
+
+ fun onScreenUpdated() {
+ onScreenUpdated(false)
+ }
+
+ fun onScreenUpdated(skipScrolling: Boolean) {
+ var skipScroll = skipScrolling
+ if (mEmulator == null) return
+
+ val rowsInHistory = mEmulator!!.getScreen().activeTranscriptRows
+ if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory
+
+ if (isSelectingText || mEmulator!!.isAutoScrollDisabled()) {
+ // Do not scroll when selecting text.
+ val rowShift = mEmulator!!.getScrollCounter()
+ if (-mTopRow + rowShift > rowsInHistory) {
+ // .. unless we're hitting the end of history transcript, in which
+ // case we abort text selection and scroll to end.
+ if (isSelectingText)
+ stopTextSelectionMode()
+
+ if (mEmulator!!.isAutoScrollDisabled()) {
+ mTopRow = -rowsInHistory
+ skipScroll = true
+ }
+ } else {
+ skipScroll = true
+ mTopRow -= rowShift
+ decrementYTextSelectionCursors(rowShift)
+ }
+ }
+
+ if (!skipScroll && mTopRow != 0) {
+ // Scroll down if not already there.
+ if (mTopRow < -3) {
+ // Awaken scroll bars only if scrolling a noticeable amount
+ awakenScrollBars()
+ }
+ mTopRow = 0
+ }
+
+ mEmulator!!.clearScrollCounter()
+
+ invalidate()
+ if (mAccessibilityEnabled) contentDescription = text
+ }
+
+ /**
+ * This must be called by the hosting activity in [Activity.onContextMenuClosed]
+ * when context menu for the [TerminalView] is started by
+ * [TextSelectionCursorController.ACTION_MORE] is closed.
+ */
+ fun onContextMenuClosed(menu: Menu) {
+ // Unset the stored text since it shouldn't be used anymore and should be cleared from memory
+ unsetStoredSelectedText()
+ }
+
+ /**
+ * Sets the text size, which in turn sets the number of rows and columns.
+ *
+ * @param textSize the new font size, in density-independent pixels.
+ */
+ fun setTextSize(textSize: Int) {
+ mRenderer = TerminalRenderer(textSize, mRenderer?.mTypeface ?: Typeface.MONOSPACE)
+ updateSize()
+ }
+
+ fun setTypeface(newTypeface: Typeface) {
+ mRenderer = TerminalRenderer(mRenderer!!.mTextSize, newTypeface)
+ updateSize()
+ invalidate()
+ }
+
+ override fun onCheckIsTextEditor(): Boolean {
+ return true
+ }
+
+ override fun isOpaque(): Boolean {
+ return true
+ }
+
+ /**
+ * Get the zero indexed column and row of the terminal view for the
+ * position of the event.
+ *
+ * @param event The event with the position to get the column and row for.
+ * @param relativeToScroll If true the column number will take the scroll
+ * position into account.
+ * @return Array with the column and row.
+ */
+ fun getColumnAndRow(event: MotionEvent, relativeToScroll: Boolean): IntArray {
+ val column = (event.x / mRenderer!!.mFontWidth).toInt()
+ var row = ((event.y - mRenderer!!.mFontLineSpacingAndAscent) / mRenderer!!.mFontLineSpacing).toInt()
+ if (relativeToScroll) {
+ row += mTopRow
+ }
+ return intArrayOf(column, row)
+ }
+
+ /** Send a single mouse event code to the terminal. */
+ fun sendMouseEventCode(e: MotionEvent, button: Int, pressed: Boolean) {
+ val columnAndRow = getColumnAndRow(e, false)
+ var x = columnAndRow[0] + 1
+ var y = columnAndRow[1] + 1
+ if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) {
+ if (mMouseStartDownTime == e.downTime) {
+ x = mMouseScrollStartX
+ y = mMouseScrollStartY
+ } else {
+ mMouseStartDownTime = e.downTime
+ mMouseScrollStartX = x
+ mMouseScrollStartY = y
+ }
+ }
+ mEmulator!!.sendMouseEvent(button, x, y, pressed)
+ }
+
+ /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */
+ fun doScroll(event: MotionEvent, rowsDown: Int) {
+ val up = rowsDown < 0
+ val amount = Math.abs(rowsDown)
+ for (i in 0 until amount) {
+ if (mEmulator!!.isMouseTrackingActive()) {
+ sendMouseEventCode(event, if (up) TerminalEmulator.MOUSE_WHEELUP_BUTTON else TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true)
+ } else if (mEmulator!!.isAlternateBufferActive()) {
+ // Send up and down key events for scrolling, which is what some terminals do to make scroll work in
+ // e.g. less, which shifts to the alt screen without mouse handling.
+ handleKeyCode(if (up) KeyEvent.KEYCODE_DPAD_UP else KeyEvent.KEYCODE_DPAD_DOWN, 0)
+ } else {
+ mTopRow = Math.min(0, Math.max(-mEmulator!!.getScreen().activeTranscriptRows, mTopRow + if (up) -1 else 1))
+ if (!awakenScrollBars()) invalidate()
+ }
+ }
+ }
+
+ /** Overriding [View.onGenericMotionEvent]. */
+ override fun onGenericMotionEvent(event: MotionEvent): Boolean {
+ if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.action == MotionEvent.ACTION_SCROLL) {
+ // Handle mouse wheel scrolling.
+ val up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f
+ doScroll(event, if (up) -3 else 3)
+ return true
+ }
+ return false
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @TargetApi(23)
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (mEmulator == null) return true
+ val action = event.action
+
+ if (isSelectingText) {
+ updateFloatingToolbarVisibility(event)
+ mGestureRecognizer.onTouchEvent(event)
+ return true
+ } else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) {
+ if (action == MotionEvent.ACTION_DOWN) showContextMenu()
+ return true
+ } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) {
+ val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = clipboardManager.primaryClip
+ if (clipData != null) {
+ val clipItem = clipData.getItemAt(0)
+ if (clipItem != null) {
+ val text = clipItem.coerceToText(context)
+ if (!TextUtils.isEmpty(text)) mEmulator!!.paste(text.toString())
+ }
+ }
+ } else if (mEmulator!!.isMouseTrackingActive()) { // BUTTON_PRIMARY.
+ when (event.action) {
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP ->
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.action == MotionEvent.ACTION_DOWN)
+ MotionEvent.ACTION_MOVE ->
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true)
+ }
+ }
+ }
+
+ mGestureRecognizer.onTouchEvent(event)
+ return true
+ }
+
+ override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logInfo(LOG_TAG, "onKeyPreIme(keyCode=$keyCode, event=$event)")
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ cancelRequestAutoFill()
+ if (isSelectingText) {
+ stopTextSelectionMode()
+ return true
+ } else if (mClient!!.shouldBackButtonBeMappedToEscape()) {
+ // Intercept back button to treat it as escape:
+ when (event.action) {
+ KeyEvent.ACTION_DOWN -> return onKeyDown(keyCode, event)
+ KeyEvent.ACTION_UP -> return onKeyUp(keyCode, event)
+ }
+ }
+ } else if (mClient!!.shouldUseCtrlSpaceWorkaround() &&
+ keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed) {
+ /* ctrl+space does not work on some ROMs without this workaround. */
+ return onKeyDown(keyCode, event)
+ }
+ return super.onKeyPreIme(keyCode, event)
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logInfo(LOG_TAG, "onKeyDown(keyCode=$keyCode, isSystem()=${event.isSystem}, event=$event)")
+ if (mEmulator == null) return true
+ if (isSelectingText) {
+ stopTextSelectionMode()
+ }
+
+ if (mClient!!.onKeyDown(keyCode, event, mTermSession!!)) {
+ invalidate()
+ return true
+ } else if (event.isSystem && (!mClient!!.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) {
+ return super.onKeyDown(keyCode, event)
+ } else if (event.action == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ mTermSession!!.write(event.characters)
+ return true
+ } else if (keyCode == KeyEvent.KEYCODE_LANGUAGE_SWITCH) {
+ return super.onKeyDown(keyCode, event)
+ }
+
+ val metaState = event.metaState
+ val controlDown = event.isCtrlPressed || mClient!!.readControlKey()
+ val leftAltDown = (metaState and KeyEvent.META_ALT_LEFT_ON) != 0 || mClient!!.readAltKey()
+ val shiftDown = event.isShiftPressed || mClient!!.readShiftKey()
+ val rightAltDownFromEvent = (metaState and KeyEvent.META_ALT_RIGHT_ON) != 0
+
+ var keyMod = 0
+ if (controlDown) keyMod = keyMod or KeyHandler.KEYMOD_CTRL
+ if (event.isAltPressed || leftAltDown) keyMod = keyMod or KeyHandler.KEYMOD_ALT
+ if (shiftDown) keyMod = keyMod or KeyHandler.KEYMOD_SHIFT
+ if (event.isNumLockOn) keyMod = keyMod or KeyHandler.KEYMOD_NUM_LOCK
+ // https://github.com/termux/termux-app/issues/731
+ if (!event.isFunctionPressed && handleKeyCode(keyCode, keyMod)) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient!!.logInfo(LOG_TAG, "handleKeyCode() took key event")
+ return true
+ }
+
+ // Clear Ctrl since we handle that ourselves:
+ var bitsToClear = KeyEvent.META_CTRL_MASK
+ if (rightAltDownFromEvent) {
+ // Let right Alt/Alt Gr be used to compose characters.
+ } else {
+ // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove:
+ bitsToClear = bitsToClear or KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON
+ }
+ var effectiveMetaState = event.metaState and bitsToClear.inv()
+
+ if (shiftDown) effectiveMetaState = effectiveMetaState or KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON
+ if (mClient!!.readFnKey()) effectiveMetaState = effectiveMetaState or KeyEvent.META_FUNCTION_ON
+
+ var result = event.getUnicodeChar(effectiveMetaState)
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar($effectiveMetaState) returned: $result")
+ if (result == 0) {
+ return false
+ }
+
+ val oldCombiningAccent = mCombiningAccent
+ if ((result and KeyCharacterMap.COMBINING_ACCENT) != 0) {
+ // If entered combining accent previously, write it out:
+ if (mCombiningAccent != 0)
+ inputCodePoint(event.deviceId, mCombiningAccent, controlDown, leftAltDown)
+ mCombiningAccent = result and KeyCharacterMap.COMBINING_ACCENT_MASK
+ } else {
+ if (mCombiningAccent != 0) {
+ val combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result)
+ if (combinedChar > 0) result = combinedChar
+ mCombiningAccent = 0
+ }
+ inputCodePoint(event.deviceId, result, controlDown, leftAltDown)
+ }
+
+ if (mCombiningAccent != oldCombiningAccent) invalidate()
+
+ return true
+ }
+
+ fun inputCodePoint(eventSource: Int, codePoint: Int, controlDownFromEvent: Boolean, leftAltDownFromEvent: Boolean) {
+ var cp = codePoint
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient!!.logInfo(LOG_TAG, "inputCodePoint(eventSource=$eventSource, codePoint=$cp, controlDownFromEvent=$controlDownFromEvent, leftAltDownFromEvent=$leftAltDownFromEvent)")
+ }
+
+ if (mTermSession == null) return
+
+ // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys
+ mEmulator?.setCursorBlinkState(true)
+
+ val controlDown = controlDownFromEvent || mClient!!.readControlKey()
+ val altDown = leftAltDownFromEvent || mClient!!.readAltKey()
+
+ if (mClient!!.onCodePoint(cp, controlDown, mTermSession!!)) return
+
+ if (controlDown) {
+ cp = when {
+ cp >= 'a'.code && cp <= 'z'.code -> cp - 'a'.code + 1
+ cp >= 'A'.code && cp <= 'Z'.code -> cp - 'A'.code + 1
+ cp == ' '.code || cp == '2'.code -> 0
+ cp == '['.code || cp == '3'.code -> 27 // ^[ (Esc)
+ cp == '\\'.code || cp == '4'.code -> 28
+ cp == ']'.code || cp == '5'.code -> 29
+ cp == '^'.code || cp == '6'.code -> 30 // control-^
+ cp == '_'.code || cp == '7'.code || cp == '/'.code -> 31
+ cp == '8'.code -> 127 // DEL
+ else -> cp
+ }
+ }
+
+ if (cp > -1) {
+ // If not virtual or soft keyboard.
+ if (eventSource > KEY_EVENT_SOURCE_SOFT_KEYBOARD) {
+ // Work around bluetooth keyboards sending funny unicode characters instead
+ // of the more normal ones from ASCII that terminal programs expect.
+ cp = when (cp) {
+ 0x02DC -> 0x007E // SMALL TILDE -> TILDE (~)
+ 0x02CB -> 0x0060 // MODIFIER LETTER GRAVE ACCENT -> GRAVE ACCENT (`)
+ 0x02C6 -> 0x005E // MODIFIER LETTER CIRCUMFLEX ACCENT -> CIRCUMFLEX ACCENT (^)
+ else -> cp
+ }
+ }
+
+ // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline:
+ mTermSession!!.writeCodePoint(altDown, cp)
+ }
+ }
+
+ /** Input the specified keyCode if applicable and return if the input was consumed. */
+ fun handleKeyCode(keyCode: Int, keyMod: Int): Boolean {
+ // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys
+ mEmulator?.setCursorBlinkState(true)
+
+ if (handleKeyCodeAction(keyCode, keyMod))
+ return true
+
+ val term = mTermSession!!.emulator
+ val code = KeyHandler.getCode(keyCode, keyMod, term!!.isCursorKeysApplicationMode(), term.isKeypadApplicationMode())
+ ?: return false
+ mTermSession!!.write(code)
+ return true
+ }
+
+ fun handleKeyCodeAction(keyCode: Int, keyMod: Int): Boolean {
+ val shiftDown = (keyMod and KeyHandler.KEYMOD_SHIFT) != 0
+
+ when (keyCode) {
+ KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_DOWN -> {
+ // shift+page_up and shift+page_down should scroll scrollback history instead of
+ // scrolling command history or changing pages
+ if (shiftDown) {
+ val time = SystemClock.uptimeMillis()
+ val motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0f, 0f, 0)
+ doScroll(motionEvent, if (keyCode == KeyEvent.KEYCODE_PAGE_UP) -mEmulator!!.mRows else mEmulator!!.mRows)
+ motionEvent.recycle()
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ /**
+ * Called when a key is released in the view.
+ *
+ * @param keyCode The keycode of the key which was released.
+ * @param event A [KeyEvent] describing the event.
+ * @return Whether the event was handled.
+ */
+ override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logInfo(LOG_TAG, "onKeyUp(keyCode=$keyCode, event=$event)")
+
+ // Do not return for KEYCODE_BACK and send it to the client since user may be trying
+ // to exit the activity.
+ if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true
+
+ if (mClient!!.onKeyUp(keyCode, event)) {
+ invalidate()
+ return true
+ } else if (event.isSystem) {
+ // Let system key events through.
+ return super.onKeyUp(keyCode, event)
+ }
+
+ return true
+ }
+
+ /**
+ * This is called during layout when the size of this view has changed. If you were just added to the view
+ * hierarchy, you're called with the old values of 0.
+ */
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ updateSize()
+ }
+
+ /** Check if the terminal size in rows and columns should be updated. */
+ fun updateSize() {
+ val viewWidth = width
+ val viewHeight = height
+ if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return
+
+ // Set to 80 and 24 if you want to enable vttest.
+ val newColumns = Math.max(4, (viewWidth / mRenderer!!.mFontWidth).toInt())
+ val newRows = Math.max(4, (viewHeight - mRenderer!!.mFontLineSpacingAndAscent) / mRenderer!!.mFontLineSpacing)
+
+ if (mEmulator == null || (newColumns != mEmulator!!.mColumns || newRows != mEmulator!!.mRows)) {
+ mTermSession!!.updateSize(newColumns, newRows, mRenderer!!.mFontWidth.toInt(), mRenderer!!.mFontLineSpacing)
+ mEmulator = mTermSession!!.emulator
+ mClient!!.onEmulatorSet()
+
+ // Update mTerminalCursorBlinkerRunnable inner class mEmulator on session change
+ mTerminalCursorBlinkerRunnable?.setEmulator(mEmulator!!)
+
+ mTopRow = 0
+ scrollTo(0, 0)
+ invalidate()
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (mEmulator == null) {
+ canvas.drawColor(0XFF000000.toInt())
+ } else {
+ // render the terminal view and highlight any selected text
+ val sel = mDefaultSelectors
+ mTextSelectionCursorController?.getSelectors(sel)
+
+ mRenderer!!.render(mEmulator!!, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3])
+
+ // render the text selection handles
+ renderTextSelection()
+ }
+ }
+
+ val currentSession: TerminalSession?
+ get() = mTermSession
+
+ private val text: CharSequence
+ get() = mEmulator!!.getScreen().getSelectedText(0, mTopRow, mEmulator!!.mColumns, mTopRow + mEmulator!!.mRows)
+
+ fun getCursorX(x: Float): Int {
+ return (x / mRenderer!!.mFontWidth).toInt()
+ }
+
+ fun getCursorY(y: Float): Int {
+ return (((y - 40) / mRenderer!!.mFontLineSpacing) + mTopRow).toInt()
+ }
+
+ fun getPointX(cx: Int): Int {
+ val col = if (cx > mEmulator!!.mColumns) mEmulator!!.mColumns else cx
+ return (col * mRenderer!!.mFontWidth).toInt()
+ }
+
+ fun getPointY(cy: Int): Int {
+ return (cy - mTopRow) * mRenderer!!.mFontLineSpacing
+ }
+
+ fun getTopRow(): Int {
+ return mTopRow
+ }
+
+ fun setTopRow(topRow: Int) {
+ this.mTopRow = topRow
+ }
+
+
+
+ /**
+ * Define functions required for AutoFill API
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ override fun autofill(value: AutofillValue) {
+ if (value.isText) {
+ mTermSession!!.write(value.textValue.toString())
+ }
+
+ resetAutoFill()
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ override fun getAutofillType(): Int {
+ return mAutoFillType
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ override fun getAutofillHints(): Array {
+ return mAutoFillHints
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ override fun getAutofillValue(): AutofillValue {
+ return AutofillValue.forText("")
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ override fun getImportantForAutofill(): Int {
+ return mAutoFillImportance
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Synchronized
+ private fun resetAutoFill() {
+ // Restore none type so that AutoFill UI isn't shown anymore.
+ mAutoFillType = AUTOFILL_TYPE_NONE
+ mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO
+ mAutoFillHints = emptyArray()
+ }
+
+ fun getAutoFillManagerService(): AutofillManager? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
+
+ return try {
+ context?.getSystemService(AutofillManager::class.java)
+ } catch (e: Exception) {
+ mClient!!.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e)
+ null
+ }
+ }
+
+ val isAutoFillEnabled: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+
+ return try {
+ val autofillManager = getAutoFillManagerService()
+ autofillManager?.isEnabled == true
+ } catch (e: Exception) {
+ mClient!!.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e)
+ false
+ }
+ }
+
+ @Synchronized
+ fun requestAutoFillUsername() {
+ requestAutoFill(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) arrayOf(AUTOFILL_HINT_USERNAME)
+ else null
+ )
+ }
+
+ @Synchronized
+ fun requestAutoFillPassword() {
+ requestAutoFill(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) arrayOf(AUTOFILL_HINT_PASSWORD)
+ else null
+ )
+ }
+
+ @Synchronized
+ fun requestAutoFill(autoFillHints: Array?) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ if (autoFillHints == null || autoFillHints.isEmpty()) return
+
+ try {
+ val autofillManager = getAutoFillManagerService()
+ if (autofillManager?.isEnabled == true) {
+ // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown.
+ mAutoFillType = AUTOFILL_TYPE_TEXT
+ // Update importance that will be returned by `getImportantForAutofill()` so that
+ // AutoFill considers the view as important.
+ mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES
+ // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI.
+ mAutoFillHints = autoFillHints
+ autofillManager.requestAutofill(this)
+ }
+ } catch (e: Exception) {
+ mClient!!.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e)
+ }
+ }
+
+ @Synchronized
+ fun cancelRequestAutoFill() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ if (mAutoFillType == AUTOFILL_TYPE_NONE) return
+
+ try {
+ val autofillManager = getAutoFillManagerService()
+ if (autofillManager?.isEnabled == true) {
+ resetAutoFill()
+ autofillManager.cancel()
+ }
+ } catch (e: Exception) {
+ mClient!!.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e)
+ }
+ }
+
+
+
+ /**
+ * Set terminal cursor blinker rate. It must be between [TERMINAL_CURSOR_BLINK_RATE_MIN]
+ * and [TERMINAL_CURSOR_BLINK_RATE_MAX], otherwise it will be disabled.
+ *
+ * @param blinkRate The value to set.
+ * @return Returns `true` if setting blinker rate was successfully set, otherwise `false`.
+ */
+ @Synchronized
+ fun setTerminalCursorBlinkerRate(blinkRate: Int): Boolean {
+ val result: Boolean
+
+ // If cursor blinking rate is not valid
+ if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) {
+ mClient!!.logError(LOG_TAG, "The cursor blink rate must be in between $TERMINAL_CURSOR_BLINK_RATE_MIN-$TERMINAL_CURSOR_BLINK_RATE_MAX: $blinkRate")
+ mTerminalCursorBlinkerRate = 0
+ result = false
+ } else {
+ mClient!!.logVerbose(LOG_TAG, "Setting cursor blinker rate to $blinkRate")
+ mTerminalCursorBlinkerRate = blinkRate
+ result = true
+ }
+
+ if (mTerminalCursorBlinkerRate == 0) {
+ mClient!!.logVerbose(LOG_TAG, "Cursor blinker disabled")
+ stopTerminalCursorBlinker()
+ }
+
+ return result
+ }
+
+ /**
+ * Sets whether cursor blinker should be started or stopped.
+ */
+ @Synchronized
+ fun setTerminalCursorBlinkerState(start: Boolean, startOnlyIfCursorEnabled: Boolean) {
+ // Stop any existing cursor blinker callbacks
+ stopTerminalCursorBlinker()
+
+ if (mEmulator == null) return
+
+ mEmulator!!.setCursorBlinkingEnabled(false)
+
+ if (start) {
+ // If cursor blinker is not enabled or is not valid
+ if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX)
+ return
+ // If cursor blinder is to be started only if cursor is enabled
+ else if (startOnlyIfCursorEnabled && !mEmulator!!.isCursorEnabled()) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled")
+ return
+ }
+
+ // Start cursor blinker runnable
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate $mTerminalCursorBlinkerRate")
+ if (mTerminalCursorBlinkerHandler == null)
+ mTerminalCursorBlinkerHandler = Handler(Looper.getMainLooper())
+ mTerminalCursorBlinkerRunnable = TerminalCursorBlinkerRunnable(mEmulator!!, mTerminalCursorBlinkerRate)
+ mEmulator!!.setCursorBlinkingEnabled(true)
+ mTerminalCursorBlinkerRunnable!!.run()
+ }
+ }
+
+ /**
+ * Cancel the terminal cursor blinker callbacks
+ */
+ private fun stopTerminalCursorBlinker() {
+ if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient!!.logVerbose(LOG_TAG, "Stopping cursor blinker")
+ mTerminalCursorBlinkerHandler!!.removeCallbacks(mTerminalCursorBlinkerRunnable!!)
+ }
+ }
+
+ private inner class TerminalCursorBlinkerRunnable(
+ private var mEmulator: TerminalEmulator,
+ private val mBlinkRate: Int
+ ) : Runnable {
+
+ // Initialize with false so that initial blink state is visible after toggling
+ private var mCursorVisible = false
+
+ fun setEmulator(emulator: TerminalEmulator) {
+ mEmulator = emulator
+ }
+
+ override fun run() {
+ try {
+ // Toggle the blink state and then invalidate() the view so
+ // that onDraw() is called, which then calls TerminalRenderer.render()
+ // which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether
+ // to draw the cursor or not
+ mCursorVisible = !mCursorVisible
+ mEmulator.setCursorBlinkState(mCursorVisible)
+ invalidate()
+ } finally {
+ // Recall the Runnable after mBlinkRate milliseconds to toggle the blink state
+ mTerminalCursorBlinkerHandler!!.postDelayed(this, mBlinkRate.toLong())
+ }
+ }
+ }
+
+
+
+ /**
+ * Define functions required for text selection and its handles.
+ */
+ fun getTextSelectionCursorController(): TextSelectionCursorController {
+ if (mTextSelectionCursorController == null) {
+ mTextSelectionCursorController = TextSelectionCursorController(this)
+
+ val observer = viewTreeObserver
+ observer?.addOnTouchModeChangeListener(mTextSelectionCursorController)
+ }
+
+ return mTextSelectionCursorController!!
+ }
+
+ private fun showTextSelectionCursors(event: MotionEvent) {
+ getTextSelectionCursorController().show(event)
+ }
+
+ private fun hideTextSelectionCursors(): Boolean {
+ return getTextSelectionCursorController().hide()
+ }
+
+ private fun renderTextSelection() {
+ mTextSelectionCursorController?.render()
+ }
+
+ val isSelectingText: Boolean
+ get() = mTextSelectionCursorController?.isActive() == true
+
+ /** Get the currently selected text if selecting. */
+ val selectedText: String?
+ get() = if (isSelectingText && mTextSelectionCursorController != null)
+ mTextSelectionCursorController!!.getSelectedText()
+ else
+ null
+
+ /** Get the selected text stored before "MORE" button was pressed on the context menu. */
+ val storedSelectedText: String?
+ get() = mTextSelectionCursorController?.storedSelectedText
+
+ /** Unset the selected text stored before "MORE" button was pressed on the context menu. */
+ fun unsetStoredSelectedText() {
+ mTextSelectionCursorController?.unsetStoredSelectedText()
+ }
+
+ private val textSelectionActionMode: ActionMode?
+ get() = mTextSelectionCursorController?.getActionMode()
+
+ fun startTextSelectionMode(event: MotionEvent) {
+ if (!requestFocus()) {
+ return
+ }
+
+ showTextSelectionCursors(event)
+ mClient!!.copyModeChanged(isSelectingText)
+
+ invalidate()
+ }
+
+ fun stopTextSelectionMode() {
+ if (hideTextSelectionCursors()) {
+ mClient!!.copyModeChanged(isSelectingText)
+ invalidate()
+ }
+ }
+
+ private fun decrementYTextSelectionCursors(decrement: Int) {
+ mTextSelectionCursorController?.decrementYTextSelectionCursors(decrement)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ if (mTextSelectionCursorController != null) {
+ viewTreeObserver.addOnTouchModeChangeListener(mTextSelectionCursorController)
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+
+ if (mTextSelectionCursorController != null) {
+ // Might solve the following exception
+ // android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow
+ stopTextSelectionMode()
+
+ viewTreeObserver.removeOnTouchModeChangeListener(mTextSelectionCursorController)
+ mTextSelectionCursorController!!.onDetached()
+ }
+ }
+
+
+
+ /**
+ * Define functions required for long hold toolbar.
+ */
+ private val mShowFloatingToolbar = Runnable {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ textSelectionActionMode?.hide(0) // hide off.
+ }
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ private fun showFloatingToolbar() {
+ if (textSelectionActionMode != null) {
+ val delay = ViewConfiguration.getDoubleTapTimeout()
+ postDelayed(mShowFloatingToolbar, delay.toLong())
+ }
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ fun hideFloatingToolbar() {
+ if (textSelectionActionMode != null) {
+ removeCallbacks(mShowFloatingToolbar)
+ textSelectionActionMode!!.hide(-1)
+ }
+ }
+
+ fun updateFloatingToolbarVisibility(event: MotionEvent) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && textSelectionActionMode != null) {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_MOVE -> hideFloatingToolbar()
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> showFloatingToolbar()
+ }
+ }
+ }
+
+ companion object {
+ /** Log terminal view key and IME events. */
+ private var TERMINAL_VIEW_KEY_LOGGING_ENABLED = false
+
+ const val TERMINAL_CURSOR_BLINK_RATE_MIN = 100
+ const val TERMINAL_CURSOR_BLINK_RATE_MAX = 2000
+
+ /** The [KeyEvent] is generated from a virtual keyboard, like manually with the [KeyEvent] constructor. */
+ const val KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD = KeyCharacterMap.VIRTUAL_KEYBOARD // -1
+
+ /** The [KeyEvent] is generated from a non-physical device, like if 0 value is returned by [KeyEvent.getDeviceId]. */
+ const val KEY_EVENT_SOURCE_SOFT_KEYBOARD = 0
+
+ private const val LOG_TAG = "TerminalView"
+ }
+}
diff --git a/app/src/main/java/com/termux/view/TerminalViewClient.kt b/app/src/main/java/com/termux/view/TerminalViewClient.kt
new file mode 100644
index 0000000..053d5f5
--- /dev/null
+++ b/app/src/main/java/com/termux/view/TerminalViewClient.kt
@@ -0,0 +1,66 @@
+package com.termux.view
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import com.termux.terminal.TerminalSession
+
+/**
+ * The interface for communication between [TerminalView] and its client.
+ * It allows for getting various configuration options from the client and for
+ * sending back data to the client like logs, key events, both hardware and IME.
+ * It must be set for the [TerminalView] through [TerminalView.setTerminalViewClient].
+ */
+interface TerminalViewClient {
+
+ /**
+ * Callback function on scale events according to [android.view.ScaleGestureDetector.getScaleFactor].
+ */
+ fun onScale(scale: Float): Float
+
+ /**
+ * On a single tap on the terminal if terminal mouse reporting not enabled.
+ */
+ fun onSingleTapUp(e: MotionEvent)
+
+ fun shouldBackButtonBeMappedToEscape(): Boolean
+
+ fun shouldEnforceCharBasedInput(): Boolean
+
+ fun shouldUseCtrlSpaceWorkaround(): Boolean
+
+ fun isTerminalViewSelected(): Boolean
+
+ fun copyModeChanged(copyMode: Boolean)
+
+ fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean
+
+ fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean
+
+ fun onLongPress(event: MotionEvent): Boolean
+
+ fun readControlKey(): Boolean
+
+ fun readAltKey(): Boolean
+
+ fun readShiftKey(): Boolean
+
+ fun readFnKey(): Boolean
+
+ fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession): Boolean
+
+ fun onEmulatorSet()
+
+ fun logError(tag: String?, message: String?)
+
+ fun logWarn(tag: String?, message: String?)
+
+ fun logInfo(tag: String?, message: String?)
+
+ fun logDebug(tag: String?, message: String?)
+
+ fun logVerbose(tag: String?, message: String?)
+
+ fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?)
+
+ fun logStackTrace(tag: String?, e: Exception?)
+}
diff --git a/app/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.kt b/app/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.kt
new file mode 100644
index 0000000..307b151
--- /dev/null
+++ b/app/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.termux.view.support
+
+import android.widget.PopupWindow
+
+/**
+ * Implementation of PopupWindow compatibility that can call Gingerbread APIs.
+ * https://chromium.googlesource.com/android_tools/+/HEAD/sdk/extras/android/support/v4/src/gingerbread/android/support/v4/widget/PopupWindowCompatGingerbread.java
+ */
+object PopupWindowCompatGingerbread {
+
+ private var setWindowLayoutTypeMethod: java.lang.reflect.Method? = null
+ private var setWindowLayoutTypeMethodAttempted = false
+ private var getWindowLayoutTypeMethod: java.lang.reflect.Method? = null
+ private var getWindowLayoutTypeMethodAttempted = false
+
+ @JvmStatic
+ fun setWindowLayoutType(popupWindow: PopupWindow, layoutType: Int) {
+ if (!setWindowLayoutTypeMethodAttempted) {
+ try {
+ setWindowLayoutTypeMethod = PopupWindow::class.java.getDeclaredMethod(
+ "setWindowLayoutType", Int::class.javaPrimitiveType
+ )
+ setWindowLayoutTypeMethod?.isAccessible = true
+ } catch (e: Exception) {
+ // Reflection method fetch failed. Oh well.
+ }
+ setWindowLayoutTypeMethodAttempted = true
+ }
+ setWindowLayoutTypeMethod?.let {
+ try {
+ it.invoke(popupWindow, layoutType)
+ } catch (e: Exception) {
+ // Reflection call failed. Oh well.
+ }
+ }
+ }
+
+ @JvmStatic
+ fun getWindowLayoutType(popupWindow: PopupWindow): Int {
+ if (!getWindowLayoutTypeMethodAttempted) {
+ try {
+ getWindowLayoutTypeMethod = PopupWindow::class.java.getDeclaredMethod(
+ "getWindowLayoutType"
+ )
+ getWindowLayoutTypeMethod?.isAccessible = true
+ } catch (e: Exception) {
+ // Reflection method fetch failed. Oh well.
+ }
+ getWindowLayoutTypeMethodAttempted = true
+ }
+ getWindowLayoutTypeMethod?.let {
+ try {
+ return it.invoke(popupWindow) as Int
+ } catch (e: Exception) {
+ // Reflection call failed. Oh well.
+ }
+ }
+ return 0
+ }
+}
diff --git a/app/src/main/java/com/termux/view/textselection/CursorController.kt b/app/src/main/java/com/termux/view/textselection/CursorController.kt
new file mode 100644
index 0000000..fb850d6
--- /dev/null
+++ b/app/src/main/java/com/termux/view/textselection/CursorController.kt
@@ -0,0 +1,53 @@
+package com.termux.view.textselection
+
+import android.view.MotionEvent
+import android.view.ViewTreeObserver
+
+/**
+ * A CursorController instance can be used to control cursors in the text.
+ * It is not used outside of [com.termux.view.TerminalView].
+ */
+interface CursorController : ViewTreeObserver.OnTouchModeChangeListener {
+
+ /**
+ * Show the cursors on screen. Will be drawn by [render] by a call during onDraw.
+ * See also [hide].
+ */
+ fun show(event: MotionEvent)
+
+ /**
+ * Hide the cursors from screen.
+ * See also [show].
+ */
+ fun hide(): Boolean
+
+ /**
+ * Render the cursors.
+ */
+ fun render()
+
+ /**
+ * Update the cursor positions.
+ */
+ fun updatePosition(handle: TextSelectionHandleView, x: Int, y: Int)
+
+ /**
+ * This method is called by [onTouchEvent] and gives the cursors
+ * a chance to become active and/or visible.
+ *
+ * @param event The touch event
+ */
+ fun onTouchEvent(event: MotionEvent): Boolean
+
+ /**
+ * Called when the view is detached from window. Perform house keeping task, such as
+ * stopping Runnable thread that would otherwise keep a reference on the context, thus
+ * preventing the activity to be recycled.
+ */
+ fun onDetached()
+
+ /**
+ * @return true if the cursors are currently active.
+ */
+ fun isActive(): Boolean
+}
diff --git a/app/src/main/java/com/termux/view/textselection/TextSelectionCursorController.kt b/app/src/main/java/com/termux/view/textselection/TextSelectionCursorController.kt
new file mode 100644
index 0000000..c9e852a
--- /dev/null
+++ b/app/src/main/java/com/termux/view/textselection/TextSelectionCursorController.kt
@@ -0,0 +1,362 @@
+package com.termux.view.textselection
+
+import android.content.ClipboardManager
+import android.content.Context
+import android.graphics.Rect
+import android.os.Build
+import android.text.TextUtils
+import android.view.ActionMode
+import android.view.Menu
+import android.view.MenuItem
+import android.view.MotionEvent
+import android.view.View
+
+import com.termux.terminal.TerminalBuffer
+import com.termux.terminal.WcWidth
+import com.termux.view.TerminalView
+
+class TextSelectionCursorController(private val terminalView: TerminalView) : CursorController {
+
+ private val mStartHandle: TextSelectionHandleView = TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT)
+ private val mEndHandle: TextSelectionHandleView = TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT)
+ private var mStoredSelectedText: String? = null
+ private var mIsSelectingText = false
+ private var mShowStartTime = System.currentTimeMillis()
+
+ private val mHandleHeight: Int = maxOf(mStartHandle.handleHeight, mEndHandle.handleHeight)
+ private var mSelX1 = -1
+ private var mSelX2 = -1
+ private var mSelY1 = -1
+ private var mSelY2 = -1
+
+ private var mActionMode: ActionMode? = null
+ val ACTION_COPY = 1
+ val ACTION_PASTE = 2
+ val ACTION_MORE = 3
+
+ override fun show(event: MotionEvent) {
+ setInitialTextSelectionPosition(event)
+ mStartHandle.positionAtCursor(mSelX1, mSelY1, true)
+ mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true)
+
+ setActionModeCallBacks()
+ mShowStartTime = System.currentTimeMillis()
+ mIsSelectingText = true
+ }
+
+ override fun hide(): Boolean {
+ if (!isActive()) return false
+
+ // prevent hide calls right after a show call, like long pressing the down key
+ // 300ms seems long enough that it wouldn't cause hide problems if action button
+ // is quickly clicked after the show, otherwise decrease it
+ if (System.currentTimeMillis() - mShowStartTime < 300) {
+ return false
+ }
+
+ mStartHandle.hide()
+ mEndHandle.hide()
+
+ mActionMode?.finish()
+
+ mSelY2 = -1
+ mSelX2 = mSelY2
+ mSelY1 = mSelX2
+ mSelX1 = mSelY1
+ mIsSelectingText = false
+
+ return true
+ }
+
+ override fun render() {
+ if (!isActive()) return
+
+ mStartHandle.positionAtCursor(mSelX1, mSelY1, false)
+ mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false)
+
+ mActionMode?.invalidate()
+ }
+
+ fun setInitialTextSelectionPosition(event: MotionEvent) {
+ val columnAndRow = terminalView.getColumnAndRow(event, true)
+ mSelX2 = columnAndRow[0]
+ mSelX1 = mSelX2
+ mSelY2 = columnAndRow[1]
+ mSelY1 = mSelY2
+
+ val screen = terminalView.mEmulator!!.getScreen()
+ if (" " != screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1)) {
+ // Selecting something other than whitespace. Expand to word.
+ while (mSelX1 > 0 && "" != screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1)) {
+ mSelX1--
+ }
+ while (mSelX2 < terminalView.mEmulator!!.mColumns - 1 && "" != screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1)) {
+ mSelX2++
+ }
+ }
+ }
+
+ fun setActionModeCallBacks() {
+ val callback = object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ val show = MenuItem.SHOW_AS_ACTION_IF_ROOM or MenuItem.SHOW_AS_ACTION_WITH_TEXT
+
+ val clipboard = terminalView.context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ val copyItem = menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, "Copy")
+ copyItem.setShowAsAction(show)
+ val pasteItem = menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, "Paste")
+ pasteItem.isEnabled = clipboard?.hasPrimaryClip() == true
+ pasteItem.setShowAsAction(show)
+ // val moreItem = menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, "More…")
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ if (!isActive()) {
+ // Fix issue where the dialog is pressed while being dismissed.
+ return true
+ }
+
+ when (item.itemId) {
+ ACTION_COPY -> {
+ val selectedText = getSelectedText()
+ terminalView.mTermSession!!.onCopyTextToClipboard(selectedText)
+ terminalView.stopTextSelectionMode()
+ }
+ ACTION_PASTE -> {
+ terminalView.stopTextSelectionMode()
+ terminalView.mTermSession!!.onPasteTextFromClipboard()
+ }
+ ACTION_MORE -> {
+ // We first store the selected text in case TerminalViewClient needs the
+ // selected text before MORE button was pressed since we are going to
+ // stop selection mode
+ mStoredSelectedText = getSelectedText()
+ // The text selection needs to be stopped before showing context menu,
+ // otherwise handles will show above popup
+ terminalView.stopTextSelectionMode()
+ terminalView.showContextMenu()
+ }
+ }
+
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {}
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ mActionMode = terminalView.startActionMode(callback)
+ return
+ }
+
+ mActionMode = terminalView.startActionMode(object : ActionMode.Callback2() {
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onCreateActionMode(mode, menu)
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return callback.onActionItemClicked(mode, item)
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {}
+
+ override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
+ var x1 = (mSelX1 * terminalView.mRenderer!!.mFontWidth).toInt()
+ var x2 = (mSelX2 * terminalView.mRenderer!!.mFontWidth).toInt()
+ val y1 = (mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer!!.mFontLineSpacing
+ val y2 = (mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer!!.mFontLineSpacing
+
+ if (x1 > x2) {
+ val tmp = x1
+ x1 = x2
+ x2 = tmp
+ }
+
+ val terminalBottom = terminalView.bottom
+ var top = y1 + mHandleHeight
+ var bottom = y2 + mHandleHeight
+ if (top > terminalBottom) top = terminalBottom
+ if (bottom > terminalBottom) bottom = terminalBottom
+
+ outRect.set(x1, top, x2, bottom)
+ }
+ }, ActionMode.TYPE_FLOATING)
+ }
+
+ override fun updatePosition(handle: TextSelectionHandleView, x: Int, y: Int) {
+ val screen = terminalView.mEmulator!!.getScreen()
+ val scrollRows = screen.activeRows - terminalView.mEmulator!!.mRows
+ if (handle === mStartHandle) {
+ mSelX1 = terminalView.getCursorX(x.toFloat())
+ mSelY1 = terminalView.getCursorY(y.toFloat())
+ if (mSelX1 < 0) {
+ mSelX1 = 0
+ }
+
+ if (mSelY1 < -scrollRows) {
+ mSelY1 = -scrollRows
+ } else if (mSelY1 > terminalView.mEmulator!!.mRows - 1) {
+ mSelY1 = terminalView.mEmulator!!.mRows - 1
+ }
+
+ if (mSelY1 > mSelY2) {
+ mSelY1 = mSelY2
+ }
+ if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
+ mSelX1 = mSelX2
+ }
+
+ if (!terminalView.mEmulator!!.isAlternateBufferActive()) {
+ var topRow = terminalView.getTopRow()
+
+ if (mSelY1 <= topRow) {
+ topRow--
+ if (topRow < -scrollRows) {
+ topRow = -scrollRows
+ }
+ } else if (mSelY1 >= topRow + terminalView.mEmulator!!.mRows) {
+ topRow++
+ if (topRow > 0) {
+ topRow = 0
+ }
+ }
+
+ terminalView.setTopRow(topRow)
+ }
+
+ mSelX1 = getValidCurX(screen, mSelY1, mSelX1)
+ } else {
+ mSelX2 = terminalView.getCursorX(x.toFloat())
+ mSelY2 = terminalView.getCursorY(y.toFloat())
+ if (mSelX2 < 0) {
+ mSelX2 = 0
+ }
+
+ if (mSelY2 < -scrollRows) {
+ mSelY2 = -scrollRows
+ } else if (mSelY2 > terminalView.mEmulator!!.mRows - 1) {
+ mSelY2 = terminalView.mEmulator!!.mRows - 1
+ }
+
+ if (mSelY1 > mSelY2) {
+ mSelY2 = mSelY1
+ }
+ if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
+ mSelX2 = mSelX1
+ }
+
+ if (!terminalView.mEmulator!!.isAlternateBufferActive()) {
+ var topRow = terminalView.getTopRow()
+
+ if (mSelY2 <= topRow) {
+ topRow--
+ if (topRow < -scrollRows) {
+ topRow = -scrollRows
+ }
+ } else if (mSelY2 >= topRow + terminalView.mEmulator!!.mRows) {
+ topRow++
+ if (topRow > 0) {
+ topRow = 0
+ }
+ }
+
+ terminalView.setTopRow(topRow)
+ }
+
+ mSelX2 = getValidCurX(screen, mSelY2, mSelX2)
+ }
+
+ terminalView.invalidate()
+ }
+
+ private fun getValidCurX(screen: TerminalBuffer, cy: Int, cx: Int): Int {
+ val line = screen.getSelectedText(0, cy, cx, cy)
+ if (!TextUtils.isEmpty(line)) {
+ var col = 0
+ var i = 0
+ val len = line.length
+ while (i < len) {
+ val ch1 = line[i]
+ if (ch1.code == 0) {
+ break
+ }
+
+ val wc: Int
+ if (Character.isHighSurrogate(ch1) && i + 1 < len) {
+ val ch2 = line[++i]
+ wc = WcWidth.width(Character.toCodePoint(ch1, ch2))
+ } else {
+ wc = WcWidth.width(ch1.code)
+ }
+
+ val cend = col + wc
+ if (cx > col && cx < cend) {
+ return cend
+ }
+ if (cend == col) {
+ return col
+ }
+ col = cend
+ i++
+ }
+ }
+ return cx
+ }
+
+ fun decrementYTextSelectionCursors(decrement: Int) {
+ mSelY1 -= decrement
+ mSelY2 -= decrement
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean = false
+
+ override fun onTouchModeChanged(isInTouchMode: Boolean) {
+ if (!isInTouchMode) {
+ terminalView.stopTextSelectionMode()
+ }
+ }
+
+ override fun onDetached() {}
+
+ override fun isActive(): Boolean = mIsSelectingText
+
+ fun getSelectors(sel: IntArray?) {
+ if (sel == null || sel.size != 4) {
+ return
+ }
+
+ sel[0] = mSelY1
+ sel[1] = mSelY2
+ sel[2] = mSelX1
+ sel[3] = mSelX2
+ }
+
+ /** Get the currently selected text. */
+ fun getSelectedText(): String {
+ return terminalView.mEmulator!!.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2)
+ }
+
+ /** Get the selected text stored before "MORE" button was pressed on the context menu. */
+ val storedSelectedText: String?
+ get() = mStoredSelectedText
+
+ /** Unset the selected text stored before "MORE" button was pressed on the context menu. */
+ fun unsetStoredSelectedText() {
+ mStoredSelectedText = null
+ }
+
+ fun getActionMode(): ActionMode? = mActionMode
+
+ /** @return true if this controller is currently used to move the start selection. */
+ val isSelectionStartDragged: Boolean
+ get() = mStartHandle.isDragging
+
+ /** @return true if this controller is currently used to move the end selection. */
+ val isSelectionEndDragged: Boolean
+ get() = mEndHandle.isDragging
+}
diff --git a/app/src/main/java/com/termux/view/textselection/TextSelectionHandleView.kt b/app/src/main/java/com/termux/view/textselection/TextSelectionHandleView.kt
new file mode 100644
index 0000000..f41bc21
--- /dev/null
+++ b/app/src/main/java/com/termux/view/textselection/TextSelectionHandleView.kt
@@ -0,0 +1,330 @@
+package com.termux.view.textselection
+
+import android.annotation.SuppressLint
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.PopupWindow
+
+import io.github.miuzarte.scrcpyforandroid.R
+import com.termux.view.TerminalView
+import com.termux.view.support.PopupWindowCompatGingerbread
+
+@SuppressLint("ViewConstructor")
+class TextSelectionHandleView(
+ private val terminalView: TerminalView,
+ private val mCursorController: CursorController,
+ private val mInitialOrientation: Int
+) : View(terminalView.context) {
+
+ private var mHandle: PopupWindow? = null
+
+ private val mHandleLeftDrawable: Drawable?
+ private val mHandleRightDrawable: Drawable?
+ private var mHandleDrawable: Drawable? = null
+
+ private var mIsDragging = false
+
+ val mTempCoords = IntArray(2)
+ var mTempRect: Rect? = null
+
+ private var mPointX = 0
+ private var mPointY = 0
+ private var mTouchToWindowOffsetX = 0f
+ private var mTouchToWindowOffsetY = 0f
+ private var mHotspotX = 0f
+ private var mHotspotY = 0f
+ private var mTouchOffsetY = 0f
+ private var mLastParentX = 0
+ private var mLastParentY = 0
+
+ private var mHandleHeight = 0
+ private var mHandleWidth = 0
+
+ private var mOrientation = 0
+
+ private var mLastTime: Long = 0
+
+ init {
+ mHandleLeftDrawable = context.getDrawable(R.drawable.text_select_handle_left_material)
+ mHandleRightDrawable = context.getDrawable(R.drawable.text_select_handle_right_material)
+
+ setOrientation(mInitialOrientation)
+ }
+
+ private fun initHandle() {
+ mHandle = PopupWindow(terminalView.context, null,
+ android.R.attr.textSelectHandleWindowStyle)
+ mHandle?.setSplitTouchEnabled(true)
+ mHandle?.isClippingEnabled = false
+ mHandle?.width = ViewGroup.LayoutParams.WRAP_CONTENT
+ mHandle?.height = ViewGroup.LayoutParams.WRAP_CONTENT
+ mHandle?.setBackgroundDrawable(null)
+ mHandle?.animationStyle = 0
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ mHandle?.windowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL
+ mHandle?.enterTransition = null
+ mHandle?.exitTransition = null
+ } else {
+ mHandle?.let { PopupWindowCompatGingerbread.setWindowLayoutType(it, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL) }
+ }
+ mHandle?.contentView = this
+ }
+
+ fun setOrientation(orientation: Int) {
+ mOrientation = orientation
+ var handleWidth = 0
+ when (orientation) {
+ LEFT -> {
+ mHandleDrawable = mHandleLeftDrawable
+ handleWidth = mHandleDrawable?.intrinsicWidth ?: 0
+ mHotspotX = (handleWidth * 3) / 4f
+ }
+ RIGHT -> {
+ mHandleDrawable = mHandleRightDrawable
+ handleWidth = mHandleDrawable?.intrinsicWidth ?: 0
+ mHotspotX = handleWidth / 4f
+ }
+ }
+
+ mHandleHeight = mHandleDrawable?.intrinsicHeight ?: 0
+
+ mHandleWidth = handleWidth
+ mTouchOffsetY = -mHandleHeight * 0.3f
+ mHotspotY = 0f
+ invalidate()
+ }
+
+ fun show() {
+ if (!isPositionVisible()) {
+ hide()
+ return
+ }
+
+ // We remove handle from its parent first otherwise the following exception may be thrown
+ // java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
+ removeFromParent()
+
+ initHandle() // init the handle
+ invalidate() // invalidate to make sure onDraw is called
+
+ val coords = mTempCoords
+ terminalView.getLocationInWindow(coords)
+ coords[0] += mPointX
+ coords[1] += mPointY
+
+ mHandle?.showAtLocation(terminalView, 0, coords[0], coords[1])
+ }
+
+ fun hide() {
+ mIsDragging = false
+
+ mHandle?.let { handle ->
+ handle.dismiss()
+
+ // We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call
+ removeFromParent()
+ mHandle = null // garbage collect the handle
+ }
+ invalidate()
+ }
+
+ fun removeFromParent() {
+ if (!isParentNull) {
+ (this.parent as ViewGroup).removeView(this)
+ }
+ }
+
+ fun positionAtCursor(cx: Int, cy: Int, forceOrientationCheck: Boolean) {
+ val x = terminalView.getPointX(cx)
+ val y = terminalView.getPointY(cy + 1)
+ moveTo(x, y, forceOrientationCheck)
+ }
+
+ private fun moveTo(x: Int, y: Int, forceOrientationCheck: Boolean) {
+ val oldHotspotX = mHotspotX
+ checkChangedOrientation(x, forceOrientationCheck)
+ mPointX = (x - (if (isShowing) oldHotspotX else mHotspotX)).toInt()
+ mPointY = y
+
+ if (isPositionVisible()) {
+ var coords: IntArray? = null
+
+ if (isShowing) {
+ coords = mTempCoords
+ terminalView.getLocationInWindow(coords)
+ val x1 = coords[0] + mPointX
+ val y1 = coords[1] + mPointY
+ mHandle?.update(x1, y1, width, height)
+ } else {
+ show()
+ }
+
+ if (mIsDragging) {
+ if (coords == null) {
+ coords = mTempCoords
+ terminalView.getLocationInWindow(coords)
+ }
+ if (coords[0] != mLastParentX || coords[1] != mLastParentY) {
+ mTouchToWindowOffsetX += coords[0] - mLastParentX
+ mTouchToWindowOffsetY += coords[1] - mLastParentY
+ mLastParentX = coords[0]
+ mLastParentY = coords[1]
+ }
+ }
+ } else {
+ hide()
+ }
+ }
+
+ fun changeOrientation(orientation: Int) {
+ if (mOrientation != orientation) {
+ setOrientation(orientation)
+ }
+ }
+
+ private fun checkChangedOrientation(posX: Int, force: Boolean) {
+ if (!mIsDragging && !force) {
+ return
+ }
+ val millis = SystemClock.currentThreadTimeMillis()
+ if (millis - mLastTime < 50 && !force) {
+ return
+ }
+ mLastTime = millis
+
+ val hostView = terminalView
+ val left = hostView.left
+ val right = hostView.width
+ val top = hostView.top
+ val bottom = hostView.height
+
+ if (mTempRect == null) {
+ mTempRect = Rect()
+ }
+ val clip = mTempRect!!
+ clip.left = left + terminalView.paddingLeft
+ clip.top = top + terminalView.paddingTop
+ clip.right = right - terminalView.paddingRight
+ clip.bottom = bottom - terminalView.paddingBottom
+
+ val parent = hostView.parent
+ if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
+ return
+ }
+
+ if (posX - mHandleWidth < clip.left) {
+ changeOrientation(RIGHT)
+ } else if (posX + mHandleWidth > clip.right) {
+ changeOrientation(LEFT)
+ } else {
+ changeOrientation(mInitialOrientation)
+ }
+ }
+
+ private fun isPositionVisible(): Boolean {
+ // Always show a dragging handle.
+ if (mIsDragging) {
+ return true
+ }
+
+ val hostView = terminalView
+ val left = 0
+ val right = hostView.width
+ val top = 0
+ val bottom = hostView.height
+
+ if (mTempRect == null) {
+ mTempRect = Rect()
+ }
+ val clip = mTempRect!!
+ clip.left = left + terminalView.paddingLeft
+ clip.top = top + terminalView.paddingTop
+ clip.right = right - terminalView.paddingRight
+ clip.bottom = bottom - terminalView.paddingBottom
+
+ val parent = hostView.parent
+ if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
+ return false
+ }
+
+ val coords = mTempCoords
+ hostView.getLocationInWindow(coords)
+ val posX = coords[0] + mPointX + mHotspotX.toInt()
+ val posY = coords[1] + mPointY + mHotspotY.toInt()
+
+ return posX >= clip.left && posX <= clip.right &&
+ posY >= clip.top && posY <= clip.bottom
+ }
+
+ override fun onDraw(c: Canvas) {
+ val width = mHandleDrawable?.intrinsicWidth ?: 0
+ val height = mHandleDrawable?.intrinsicHeight ?: 0
+ mHandleDrawable?.setBounds(0, 0, width, height)
+ mHandleDrawable?.draw(c)
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ terminalView.updateFloatingToolbarVisibility(event)
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ val rawX = event.rawX
+ val rawY = event.rawY
+ mTouchToWindowOffsetX = rawX - mPointX
+ mTouchToWindowOffsetY = rawY - mPointY
+ val coords = mTempCoords
+ terminalView.getLocationInWindow(coords)
+ mLastParentX = coords[0]
+ mLastParentY = coords[1]
+ mIsDragging = true
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ val rawX = event.rawX
+ val rawY = event.rawY
+
+ val newPosX = rawX - mTouchToWindowOffsetX + mHotspotX
+ val newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY
+
+ mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY))
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_CANCEL -> mIsDragging = false
+ }
+ return true
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ setMeasuredDimension(mHandleDrawable?.intrinsicWidth ?: 0,
+ mHandleDrawable?.intrinsicHeight ?: 0)
+ }
+
+ val handleHeight: Int
+ get() = mHandleHeight
+
+ val handleWidth: Int
+ get() = mHandleWidth
+
+ val isShowing: Boolean
+ get() = mHandle?.isShowing == true
+
+ val isParentNull: Boolean
+ get() = this.parent == null
+
+ val isDragging: Boolean
+ get() = mIsDragging
+
+ companion object {
+ const val LEFT = 0
+ const val RIGHT = 2
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt
index bb492e4..bda8b73 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt
@@ -10,7 +10,6 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
-import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import kotlinx.coroutines.runBlocking
// 生物认证需要 FragmentActivity
@@ -21,12 +20,7 @@ class MainActivity : FragmentActivity() {
AppRuntime.init(applicationContext)
AppWakeLocks.init(applicationContext)
- val migration = PreferenceMigration(applicationContext)
runBlocking {
- // 旧版设置迁移
- if (migration.needsMigration())
- migration.migrate(clearSharedPrefs = true)
-
PasswordRepository.refresh()
// 认证不可用时, 清除经认证创建的密码
if (!BiometricGate.canAuthenticate()) {
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
index 9b3bb55..0c2c081 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
@@ -714,8 +714,19 @@ fun DeviceTabPage(
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
scrcpy.stop()
if (options.killAdbOnClose) {
- // TODO
- disconnectAdbConnection()
+ currentTarget?.host?.let { sessionReconnectBlacklistHosts += it }
+ disconnectAdbConnection(
+ clearQuickOnlineForTarget = currentTarget,
+ logMessage = "scrcpy 已停止,ADB 已断开",
+ showSnackMessage = "scrcpy 已停止,ADB 已断开",
+ )
+ } else {
+ AppWakeLocks.release()
+ adbSession = adbSession.copy(
+ statusLine = "${currentTarget!!.host}:${currentTarget.port}"
+ )
+ logEvent("scrcpy 已停止")
+ snackbar.show("scrcpy 已停止")
}
}
@@ -805,7 +816,7 @@ fun DeviceTabPage(
) {
if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
adbBackgroundRunner.runAutoReconnectLoop(
- isConnected = { adbConnected },
+ isConnected = { false },
isForeground = { isAppInForeground },
isAutoReconnectEnabled = { asBundle.adbAutoReconnectPairedDevice },
isBusy = { busy },
@@ -1093,13 +1104,7 @@ fun DeviceTabPage(
},
onStop = {
runBusy("停止 scrcpy") {
- scrcpy.stop()
- AppWakeLocks.release()
- adbSession = adbSession.copy(
- statusLine = "${currentTarget!!.host}:${currentTarget.port}"
- )
- logEvent("scrcpy 已停止")
- snackbar.show("scrcpy 已停止")
+ stopScrcpySession()
}
},
sessionInfo = sessionInfo,
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
index e7f27bb..cf55591 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
@@ -1,9 +1,12 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
+import android.content.Context
import android.graphics.Rect
+import android.hardware.display.DisplayManager
import android.util.Log
import android.view.KeyEvent
+import android.view.Surface
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
@@ -38,8 +41,10 @@ import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -51,6 +56,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
+import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
@@ -69,6 +75,7 @@ import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.Text
+@SuppressLint("NewApi")
@Composable
fun FullscreenControlScreen(
scrcpy: Scrcpy,
@@ -80,6 +87,7 @@ fun FullscreenControlScreen(
BackHandler(enabled = true, onBack = onBack)
val activity = LocalActivity.current
+ val context = LocalContext.current
val fragmentActivity = remember(activity) { activity as? FragmentActivity }
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
@@ -122,7 +130,94 @@ fun FullscreenControlScreen(
}
val fullscreenDebugInfo = asBundle.fullscreenDebugInfo
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
- val showFullscreenFloatingButton = asBundle.showFullscreenFloatingButton
+ val fullscreenVirtualButtonHeight = asBundle.fullscreenVirtualButtonHeightDp.dp
+ val fullscreenVirtualButtonDockSetting = remember(asBundle.fullscreenVirtualButtonDock) {
+ AppSettings.FullscreenVirtualButtonDock.fromStoredValue(
+ asBundle.fullscreenVirtualButtonDock
+ )
+ }
+ var displayRotation by remember(activity) {
+ mutableIntStateOf(activity?.display?.rotation ?: Surface.ROTATION_0)
+ }
+ DisposableEffect(activity, context) {
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
+ if (displayManager == null || activity == null) {
+ onDispose {}
+ } else {
+ val listener = object : DisplayManager.DisplayListener {
+ override fun onDisplayAdded(displayId: Int) = Unit
+
+ override fun onDisplayRemoved(displayId: Int) = Unit
+
+ override fun onDisplayChanged(displayId: Int) {
+ if (displayId == activity.display?.displayId) {
+ displayRotation = activity.display?.rotation ?: Surface.ROTATION_0
+ }
+ }
+ }
+ displayRotation = activity.display?.rotation ?: Surface.ROTATION_0
+ displayManager.registerDisplayListener(listener, null)
+ onDispose {
+ displayManager.unregisterDisplayListener(listener)
+ }
+ }
+ }
+ val fullscreenVirtualButtonPhysicalDock = remember(fullscreenVirtualButtonDockSetting) {
+ when (fullscreenVirtualButtonDockSetting) {
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_TOP,
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_BOTTOM,
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_LEFT,
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_RIGHT -> null
+
+ AppSettings.FullscreenVirtualButtonDock.FIXED_TOP -> VirtualButtonBar.FullscreenDock.TOP
+ AppSettings.FullscreenVirtualButtonDock.FIXED_BOTTOM -> VirtualButtonBar.FullscreenDock.BOTTOM
+ AppSettings.FullscreenVirtualButtonDock.FIXED_LEFT -> VirtualButtonBar.FullscreenDock.LEFT
+ AppSettings.FullscreenVirtualButtonDock.FIXED_RIGHT -> VirtualButtonBar.FullscreenDock.RIGHT
+ }
+ }
+ val fullscreenVirtualButtonDock = remember(
+ fullscreenVirtualButtonDockSetting,
+ displayRotation,
+ ) {
+ when (fullscreenVirtualButtonDockSetting) {
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_TOP -> VirtualButtonBar.FullscreenDock.TOP
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_BOTTOM -> VirtualButtonBar.FullscreenDock.BOTTOM
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_LEFT -> VirtualButtonBar.FullscreenDock.LEFT
+ AppSettings.FullscreenVirtualButtonDock.FOLLOW_RIGHT -> VirtualButtonBar.FullscreenDock.RIGHT
+ AppSettings.FullscreenVirtualButtonDock.FIXED_TOP -> dockForFixedPhysicalEdge(
+ physicalDock = VirtualButtonBar.FullscreenDock.TOP,
+ displayRotation = displayRotation,
+ )
+
+ AppSettings.FullscreenVirtualButtonDock.FIXED_BOTTOM -> dockForFixedPhysicalEdge(
+ physicalDock = VirtualButtonBar.FullscreenDock.BOTTOM,
+ displayRotation = displayRotation,
+ )
+
+ AppSettings.FullscreenVirtualButtonDock.FIXED_LEFT -> dockForFixedPhysicalEdge(
+ physicalDock = VirtualButtonBar.FullscreenDock.LEFT,
+ displayRotation = displayRotation,
+ )
+
+ AppSettings.FullscreenVirtualButtonDock.FIXED_RIGHT -> dockForFixedPhysicalEdge(
+ physicalDock = VirtualButtonBar.FullscreenDock.RIGHT,
+ displayRotation = displayRotation,
+ )
+ }
+ }
+ val fullscreenVirtualButtonReverseOrder = remember(
+ fullscreenVirtualButtonPhysicalDock,
+ displayRotation,
+ ) {
+ fullscreenVirtualButtonPhysicalDock
+ ?.let { physicalDock ->
+ isFixedDockOrderReversed(
+ physicalDock = physicalDock,
+ displayRotation = displayRotation,
+ )
+ }
+ ?: false
+ }
val bar = remember(buttonItems) {
VirtualButtonBar(
@@ -269,7 +364,7 @@ fun FullscreenControlScreen(
interactive = !isInPip,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText,
- onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
+ onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
withContext(Dispatchers.IO) {
scrcpy.injectTouch(
action = action,
@@ -279,16 +374,31 @@ fun FullscreenControlScreen(
screenWidth = session.width,
screenHeight = session.height,
pressure = pressure,
- actionButton = 0,
+ actionButton = actionButton,
buttons = buttons,
)
}
},
+ onBackOrScreenOn = { action ->
+ withContext(Dispatchers.IO) {
+ scrcpy.pressBackOrTurnScreenOn(action)
+ }
+ },
)
if (showFullscreenVirtualButtons && !isInPip) {
bar.Fullscreen(
- modifier = Modifier.align(Alignment.BottomCenter),
+ modifier = Modifier.align(
+ when (fullscreenVirtualButtonDock) {
+ VirtualButtonBar.FullscreenDock.TOP -> Alignment.TopCenter
+ VirtualButtonBar.FullscreenDock.BOTTOM -> Alignment.BottomCenter
+ VirtualButtonBar.FullscreenDock.LEFT -> Alignment.CenterStart
+ VirtualButtonBar.FullscreenDock.RIGHT -> Alignment.CenterEnd
+ }
+ ),
+ dock = fullscreenVirtualButtonDock,
+ reverseOrder = fullscreenVirtualButtonReverseOrder,
+ thickness = fullscreenVirtualButtonHeight,
onAction = { action ->
when (action) {
VirtualButtonAction.RECENT_TASKS -> {
@@ -331,7 +441,7 @@ fun FullscreenControlScreen(
)
}
- if (showFullscreenFloatingButton && !isInPip) {
+ if (asBundle.showFullscreenFloatingButton && !isInPip) {
bar.FloatingBall(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
@@ -365,15 +475,13 @@ fun FullscreenControlScreen(
}
}
},
- passwordPopupContent = if (fragmentActivity == null) {
- null
- } else {
- { onDismissRequest ->
+ passwordPopupContent =
+ if (fragmentActivity == null) null
+ else { onDismissRequest ->
PasswordPickerPopupContent(
onDismissRequest = onDismissRequest,
)
- }
- },
+ },
)
}
@@ -451,6 +559,101 @@ fun FullscreenControlScreen(
}
}
+private fun dockForFixedPhysicalEdge(
+ physicalDock: VirtualButtonBar.FullscreenDock,
+ displayRotation: Int,
+) = when (displayRotation) {
+ Surface.ROTATION_0 -> physicalDock
+ Surface.ROTATION_90 -> when (physicalDock) {
+ VirtualButtonBar.FullscreenDock.TOP -> VirtualButtonBar.FullscreenDock.LEFT
+ VirtualButtonBar.FullscreenDock.BOTTOM -> VirtualButtonBar.FullscreenDock.RIGHT
+ VirtualButtonBar.FullscreenDock.LEFT -> VirtualButtonBar.FullscreenDock.BOTTOM
+ VirtualButtonBar.FullscreenDock.RIGHT -> VirtualButtonBar.FullscreenDock.TOP
+ }
+
+ Surface.ROTATION_180 -> when (physicalDock) {
+ VirtualButtonBar.FullscreenDock.TOP -> VirtualButtonBar.FullscreenDock.BOTTOM
+ VirtualButtonBar.FullscreenDock.BOTTOM -> VirtualButtonBar.FullscreenDock.TOP
+ VirtualButtonBar.FullscreenDock.LEFT -> VirtualButtonBar.FullscreenDock.RIGHT
+ VirtualButtonBar.FullscreenDock.RIGHT -> VirtualButtonBar.FullscreenDock.LEFT
+ }
+
+ Surface.ROTATION_270 -> when (physicalDock) {
+ VirtualButtonBar.FullscreenDock.TOP -> VirtualButtonBar.FullscreenDock.RIGHT
+ VirtualButtonBar.FullscreenDock.BOTTOM -> VirtualButtonBar.FullscreenDock.LEFT
+ VirtualButtonBar.FullscreenDock.LEFT -> VirtualButtonBar.FullscreenDock.TOP
+ VirtualButtonBar.FullscreenDock.RIGHT -> VirtualButtonBar.FullscreenDock.BOTTOM
+ }
+
+ else -> physicalDock
+}
+
+private enum class DockDirection {
+ LEFT_TO_RIGHT,
+ RIGHT_TO_LEFT,
+ TOP_TO_BOTTOM,
+ BOTTOM_TO_TOP,
+}
+
+private fun isFixedDockOrderReversed(
+ physicalDock: VirtualButtonBar.FullscreenDock,
+ displayRotation: Int,
+): Boolean {
+ val visualDock = dockForFixedPhysicalEdge(
+ physicalDock = physicalDock,
+ displayRotation = displayRotation,
+ )
+ val visualDirection = rotateDockDirection(
+ direction = physicalDockBaseDirection(physicalDock),
+ displayRotation = displayRotation,
+ )
+ return when (visualDock) {
+ VirtualButtonBar.FullscreenDock.TOP,
+ VirtualButtonBar.FullscreenDock.BOTTOM -> visualDirection == DockDirection.RIGHT_TO_LEFT
+
+ VirtualButtonBar.FullscreenDock.LEFT,
+ VirtualButtonBar.FullscreenDock.RIGHT -> visualDirection == DockDirection.BOTTOM_TO_TOP
+ }
+}
+
+private fun physicalDockBaseDirection(
+ physicalDock: VirtualButtonBar.FullscreenDock,
+) = when (physicalDock) {
+ VirtualButtonBar.FullscreenDock.TOP -> DockDirection.RIGHT_TO_LEFT
+ VirtualButtonBar.FullscreenDock.BOTTOM -> DockDirection.LEFT_TO_RIGHT
+ VirtualButtonBar.FullscreenDock.LEFT -> DockDirection.TOP_TO_BOTTOM
+ VirtualButtonBar.FullscreenDock.RIGHT -> DockDirection.BOTTOM_TO_TOP
+}
+
+private fun rotateDockDirection(
+ direction: DockDirection,
+ displayRotation: Int,
+) = when (displayRotation) {
+ Surface.ROTATION_0 -> direction
+ Surface.ROTATION_90 -> when (direction) {
+ DockDirection.LEFT_TO_RIGHT -> DockDirection.BOTTOM_TO_TOP
+ DockDirection.RIGHT_TO_LEFT -> DockDirection.TOP_TO_BOTTOM
+ DockDirection.TOP_TO_BOTTOM -> DockDirection.LEFT_TO_RIGHT
+ DockDirection.BOTTOM_TO_TOP -> DockDirection.RIGHT_TO_LEFT
+ }
+
+ Surface.ROTATION_180 -> when (direction) {
+ DockDirection.LEFT_TO_RIGHT -> DockDirection.RIGHT_TO_LEFT
+ DockDirection.RIGHT_TO_LEFT -> DockDirection.LEFT_TO_RIGHT
+ DockDirection.TOP_TO_BOTTOM -> DockDirection.BOTTOM_TO_TOP
+ DockDirection.BOTTOM_TO_TOP -> DockDirection.TOP_TO_BOTTOM
+ }
+
+ Surface.ROTATION_270 -> when (direction) {
+ DockDirection.LEFT_TO_RIGHT -> DockDirection.TOP_TO_BOTTOM
+ DockDirection.RIGHT_TO_LEFT -> DockDirection.BOTTOM_TO_TOP
+ DockDirection.TOP_TO_BOTTOM -> DockDirection.RIGHT_TO_LEFT
+ DockDirection.BOTTOM_TO_TOP -> DockDirection.LEFT_TO_RIGHT
+ }
+
+ else -> direction
+}
+
/**
* FullscreenControlScreen
*
@@ -479,7 +682,16 @@ fun FullscreenControlPage(
interactive: Boolean = true,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
onImeCommitText: suspend (String) -> Unit,
- onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
+ onInjectTouch: suspend (
+ action: Int,
+ pointerId: Long,
+ x: Int,
+ y: Int,
+ pressure: Float,
+ actionButton: Int,
+ buttons: Int,
+ ) -> Unit,
+ onBackOrScreenOn: suspend (action: Int) -> Unit,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -506,7 +718,9 @@ fun FullscreenControlPage(
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
+ mouseHoverEnabled = session.mouseHover,
onInjectTouch = onInjectTouch,
+ onBackOrScreenOn = onBackOrScreenOn,
onActiveTouchCountChanged = { activeTouchCount = it },
onActiveTouchDebugChanged = { activeTouchDebug = it },
onNextPointerLabelChanged = { nextPointerLabel = it },
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/LocalServerPicker.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/LocalServerPicker.kt
deleted file mode 100644
index 9f8fe29..0000000
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/LocalServerPicker.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package io.github.miuzarte.scrcpyforandroid.pages
-
-import androidx.compose.runtime.staticCompositionLocalOf
-
-class ServerPicker(
- val pick: () -> Unit,
-)
-
-val LocalServerPicker = staticCompositionLocalOf {
- error("No ServerPicker provided")
-}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
index e5c287d..e9cd640 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
+import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
@@ -39,6 +40,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -98,6 +100,37 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.ThemeController
import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop
+import java.io.File
+
+private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
+
+private fun terminalFontFile(context: android.content.Context): File {
+ return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
+}
+
+private fun copyTerminalFontToPrivate(context: android.content.Context, uri: Uri) {
+ val target = terminalFontFile(context)
+ target.parentFile?.mkdirs()
+ context.contentResolver.openInputStream(uri).use { input ->
+ requireNotNull(input) { "无法读取字体文件" }
+ target.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+}
+
+private fun queryDisplayName(context: android.content.Context, uri: Uri): String? {
+ return context.contentResolver
+ .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
+ ?.use { cursor ->
+ val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (columnIndex >= 0 && cursor.moveToFirst()) {
+ cursor.getString(columnIndex)
+ } else {
+ null
+ }
+ }
+}
private enum class MainBottomTabDestination(
val label: String,
@@ -155,6 +188,7 @@ fun MainScreen() {
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
var fileTabCanNavigateUp by remember { mutableStateOf(false) }
var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) }
+ var terminalGestureLock by remember { mutableStateOf(false) }
// Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior(
@@ -282,6 +316,43 @@ fun MainScreen() {
)
}
}
+ val terminalFontDocumentPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri: Uri? ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ taskScope.launch {
+ val result = runCatching {
+ val displayName = queryDisplayName(context, uri)
+ ?.takeIf { it.isNotBlank() }
+ ?: "font.ttf"
+ copyTerminalFontToPrivate(context, uri)
+ displayName
+ }
+ withContext(Dispatchers.Main) {
+ result.onSuccess { displayName ->
+ asBundle = asBundle.copy(terminalFontDisplayName = displayName)
+ snackbarController.show("终端字体导入成功")
+ }.onFailure { error ->
+ snackbarController.show(
+ "终端字体导入失败: ${error.message ?: error.javaClass.simpleName}"
+ )
+ }
+ }
+ }
+ }
+ val terminalFontPicker = remember(terminalFontDocumentPicker) {
+ TerminalFontPicker {
+ terminalFontDocumentPicker.launch(
+ arrayOf(
+ "font/ttf",
+ "font/otf",
+ "application/x-font-ttf",
+ "application/x-font-otf",
+ "*/*",
+ )
+ )
+ }
+ }
// Derived flags
val canNavigateBack = rootBackStack.size > 1
@@ -451,6 +522,7 @@ fun MainScreen() {
),
state = pagerState,
beyondViewportPageCount = 1,
+ userScrollEnabled = !(selectedTabIndex == MainBottomTabDestination.Terminal.ordinal && terminalGestureLock),
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
@@ -464,6 +536,10 @@ fun MainScreen() {
MainBottomTabDestination.Terminal -> TerminalScreen(
bottomInnerPadding = bottomInnerPadding,
+ isActive = selectedTabIndex == MainBottomTabDestination.Terminal.ordinal,
+ onTerminalGestureLockChanged = { locked ->
+ terminalGestureLock = locked
+ },
)
MainBottomTabDestination.Files -> FileManagerScreen(
@@ -575,6 +651,7 @@ fun MainScreen() {
LocalSnackbarController provides snackbarController,
LocalAppHaptics provides haptics,
LocalServerPicker provides serverPicker,
+ LocalTerminalFontPicker provides terminalFontPicker,
) {
NavDisplay(
entries = rootEntries,
@@ -583,3 +660,19 @@ fun MainScreen() {
}
}
}
+
+class ServerPicker(
+ val pick: () -> Unit,
+)
+
+class TerminalFontPicker(
+ val pick: () -> Unit,
+)
+
+val LocalServerPicker = staticCompositionLocalOf {
+ error("No ServerPicker provided")
+}
+
+val LocalTerminalFontPicker = staticCompositionLocalOf {
+ error("No TerminalFontPicker provided")
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt
index 9a53f7e..6dd2168 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt
@@ -1008,11 +1008,7 @@ internal fun ScrcpyAllOptionsPage(
soBundle = soBundle.copy(
killAdbOnClose = it
)
- if (it) snackbar.show(
- "未实现"
- )
},
- enabled = false,
)
}
}
@@ -1615,6 +1611,16 @@ internal fun ScrcpyAllOptionsPage(
)
},
)
+ SwitchPreference(
+ title = "禁用鼠标悬停转发",
+ summary = "--no-mouse-hover",
+ checked = !soBundle.mouseHover,
+ onCheckedChange = {
+ soBundle = soBundle.copy(
+ mouseHover = !it
+ )
+ },
+ )
SwitchPreference(
title = "禁用结束后清理",
summary = "--no-cleanup",
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
index 737b62c..f7dd852 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
@@ -1,7 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
-import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -43,17 +42,20 @@ import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
-import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
+import io.github.miuzarte.scrcpyforandroid.storage.AppSettings.FullscreenVirtualButtonDock
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
+import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup
+import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownPreference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
@@ -67,10 +69,21 @@ import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
+import java.io.File
import kotlin.math.roundToInt
-import kotlin.system.exitProcess
import android.provider.Settings as AndroidSettings
+private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
+
+private fun terminalFontFile(context: android.content.Context): File {
+ return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
+}
+
+private fun clearTerminalFont(context: android.content.Context): Boolean {
+ val target = terminalFontFile(context)
+ return target.exists() && target.delete()
+}
+
@Composable
fun SettingsScreen(
scrollBehavior: ScrollBehavior,
@@ -117,11 +130,7 @@ fun SettingsPage(
) {
val context = LocalContext.current
val appContext = context.applicationContext
- var needMigration by remember { mutableStateOf(false) }
val updateState by AppUpdateChecker.state.collectAsState()
- LaunchedEffect(Unit) {
- needMigration = PreferenceMigration(appContext).needsMigration()
- }
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
@@ -129,6 +138,7 @@ fun SettingsPage(
val snackbar = LocalSnackbarController.current
val navigator = LocalRootNavigator.current
val serverPicker = LocalServerPicker.current
+ val terminalFontPicker = LocalTerminalFontPicker.current
val isScrcpyStreaming = AppRuntime.scrcpy?.isStarted() == true
val asBundleShared by appSettings.bundleState.collectAsState()
@@ -156,6 +166,10 @@ fun SettingsPage(
val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } }
+ val fullscreenVirtualButtonDock = remember(asBundle.fullscreenVirtualButtonDock) {
+ FullscreenVirtualButtonDock.fromStoredValue(asBundle.fullscreenVirtualButtonDock)
+ }
+
val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) {
asBundle.customServerUri.isNotBlank()
}
@@ -206,13 +220,7 @@ fun SettingsPage(
Card {
OverlayDropdownPreference(
title = "外观模式",
- summary = ThemeModes.baseOptions
- .getOrNull(
- asBundle.themeBaseIndex
- .coerceIn(0, ThemeModes.baseOptions.lastIndex)
- )
- ?.label
- ?: "跟随系统",
+ summary = "选择应用的外观模式",
items = themeItems,
selectedIndex = asBundle.themeBaseIndex
.coerceIn(0, ThemeModes.baseOptions.lastIndex),
@@ -305,11 +313,12 @@ fun SettingsPage(
value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
- devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
+ devicePreviewCardHeightDp =
+ it.roundToInt().coerceAtLeast(120)
)
},
valueRange = 160f..600f,
- steps = 600 - 160 - 2,
+ steps = 600 - 160 - 1,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
@@ -340,22 +349,178 @@ fun SettingsPage(
context.startActivity(LockscreenPasswordActivity.createIntent(context))
},
)
+ SwitchPreference(
+ title = "全屏时不跟随系统旋转锁定",
+ summary = "启用后使用传感器方向,忽略系统自动旋转锁定状态",
+ checked = asBundle.fullscreenControlIgnoreSystemRotationLock,
+ onCheckedChange = {
+ asBundle = asBundle.copy(
+ fullscreenControlIgnoreSystemRotationLock = it
+ )
+ },
+ )
SwitchPreference(
title = "全屏显示虚拟按钮",
- summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
+ summary = "在全屏控制页中显示返回键、主页键等虚拟按钮",
checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
},
)
+ AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
+ Column {
+ MultiGroupsDropdownPreference(
+ title = "全屏虚拟按钮方向",
+ summary = fullscreenVirtualButtonDock.summary,
+ groups = listOf(
+ MultiGroupsDropdownGroup(
+ options = FullscreenVirtualButtonDock.modeItems,
+ selectedIndex = fullscreenVirtualButtonDock.modeIndex,
+ onSelectedIndexChange = { modeIndex ->
+ asBundle = asBundle.copy(
+ fullscreenVirtualButtonDock = FullscreenVirtualButtonDock
+ .fromModeAndDirection(
+ modeIndex = modeIndex,
+ directionIndex = fullscreenVirtualButtonDock.directionIndex,
+ )
+ .toStoredValue()
+ )
+ },
+ ),
+ MultiGroupsDropdownGroup(
+ options = FullscreenVirtualButtonDock.directionItems,
+ selectedIndex = fullscreenVirtualButtonDock.directionIndex,
+ onSelectedIndexChange = { directionIndex ->
+ asBundle = asBundle.copy(
+ fullscreenVirtualButtonDock = FullscreenVirtualButtonDock
+ .fromModeAndDirection(
+ modeIndex = fullscreenVirtualButtonDock.modeIndex,
+ directionIndex = directionIndex,
+ )
+ .toStoredValue()
+ )
+ },
+ ),
+ ),
+ )
+ SuperSlider(
+ title = "全屏虚拟按钮高度",
+ summary = "调整全屏控制页中虚拟按钮栏的高度",
+ value = asBundle.fullscreenVirtualButtonHeightDp.toFloat(),
+ onValueChange = {
+ asBundle = asBundle.copy(
+ fullscreenVirtualButtonHeightDp =
+ it.roundToInt().coerceIn(16, 80)
+ )
+ },
+ valueRange = 16f..80f,
+ steps = 80 - 16 - 1,
+ unit = "dp",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = asBundle.fullscreenVirtualButtonHeightDp.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 1f..160f,
+ onInputConfirm = { input ->
+ input.toIntOrNull()?.let {
+ asBundle = asBundle.copy(
+ fullscreenVirtualButtonHeightDp =
+ it.coerceIn(1, 160)
+ )
+ }
+ },
+ )
+ }
+ }
SwitchPreference(
title = "全屏显示悬浮球",
- summary = "在全屏控制页显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单",
+ summary = "在全屏控制页中显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单",
checked = asBundle.showFullscreenFloatingButton,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenFloatingButton = it)
},
)
+ AnimatedVisibility(asBundle.showFullscreenFloatingButton) {
+ Column {
+ SuperSlider(
+ title = "全屏悬浮球尺寸",
+ summary = "调整全屏控制页悬浮球大小",
+ value = asBundle.fullscreenFloatingButtonSizeDp.toFloat(),
+ onValueChange = {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonSizeDp =
+ it.roundToInt().coerceIn(32, 64)
+ )
+ },
+ valueRange = 32f..64f,
+ steps = 64 - 32 - 1,
+ unit = "dp",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = asBundle.fullscreenFloatingButtonSizeDp.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 16f..96f,
+ onInputConfirm = { input ->
+ input.toIntOrNull()?.let {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonSizeDp =
+ it.coerceIn(16, 96)
+ )
+ }
+ },
+ )
+ SuperSlider(
+ title = "悬浮球背景透明度",
+ summary = "调整悬浮球背景透明度",
+ value = asBundle.fullscreenFloatingButtonBackgroundAlphaPercent.toFloat(),
+ onValueChange = {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonBackgroundAlphaPercent =
+ it.roundToInt().coerceIn(10, 100)
+ )
+ },
+ valueRange = 10f..100f,
+ steps = 100 - 10 - 1,
+ unit = "%",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = asBundle.fullscreenFloatingButtonBackgroundAlphaPercent.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 10f..100f,
+ onInputConfirm = { input ->
+ input.toIntOrNull()?.let {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonBackgroundAlphaPercent =
+ it.coerceIn(10, 100)
+ )
+ }
+ },
+ )
+ SuperSlider(
+ title = "白环透明度",
+ summary = "调整悬浮球白环透明度",
+ value = asBundle.fullscreenFloatingButtonRingAlphaPercent.toFloat(),
+ onValueChange = {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonRingAlphaPercent =
+ it.roundToInt().coerceIn(0, 100)
+ )
+ },
+ valueRange = 0f..100f,
+ steps = 100 - 0 - 1,
+ unit = "%",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = asBundle.fullscreenFloatingButtonRingAlphaPercent.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 0f..100f,
+ onInputConfirm = { input ->
+ input.toIntOrNull()?.let {
+ asBundle = asBundle.copy(
+ fullscreenFloatingButtonRingAlphaPercent =
+ it.coerceIn(0, 100)
+ )
+ }
+ },
+ )
+ }
+ }
SwitchPreference(
title = "实时同步剪贴板到受控机",
summary =
@@ -366,7 +531,9 @@ fun SettingsPage(
""".trimIndent(),
checked = asBundle.realtimeClipboardSyncToDevice,
onCheckedChange = {
- asBundle = asBundle.copy(realtimeClipboardSyncToDevice = it)
+ asBundle = asBundle.copy(
+ realtimeClipboardSyncToDevice = it
+ )
},
)
}
@@ -592,38 +759,90 @@ fun SettingsPage(
}
}
- if (needMigration) item {
- // 这部分应该不会显示出来,
- // 应用启动时就会执行迁移与旧数据的删除
- SectionSmallTitle("应用")
+ item {
+ SectionSmallTitle("终端")
Card {
- ArrowPreference(
- title = "恢复旧版本配置",
- summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
- onClick = {
- scope.launch {
- val migration = PreferenceMigration(appContext)
- migration.migrate(clearSharedPrefs = false)
- snackbar.show("迁移完成,应用将重启")
-
- delay(1000)
-
- val intent = context.packageManager.getLaunchIntentForPackage(
- context.packageName
+ SuperSlider(
+ title = "终端字号",
+ summary = "也可以在终端上双指缩放调整",
+ value = asBundle.terminalFontSizeSp,
+ onValueChange = {
+ asBundle = asBundle.copy(
+ terminalFontSizeSp = it.roundToInt().toFloat()
+ )
+ },
+ valueRange = 1f..32f,
+ steps = 32 - 1 - 1,
+ unit = "sp",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = asBundle.terminalFontSizeSp.roundToInt().toString(),
+ inputFilter = { input -> input.filter(Char::isDigit) },
+ inputValueRange = 1f..32f,
+ onInputConfirm = { input ->
+ input.toIntOrNull()?.let {
+ asBundle = asBundle.copy(
+ terminalFontSizeSp = it.coerceIn(1, 32).toFloat()
)
- intent?.apply {
- addFlags(
- Intent.FLAG_ACTIVITY_NEW_TASK
- or Intent.FLAG_ACTIVITY_CLEAR_TASK
- )
- }
- context.startActivity(intent)
-
- Process.killProcess(Process.myPid())
- exitProcess(0)
}
},
)
+ Column(
+ modifier = Modifier.padding(vertical = UiSpacing.Large),
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = UiSpacing.Large),
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ Text(
+ text = "自定义终端字体",
+ fontWeight = FontWeight.Medium,
+ )
+ TextField(
+ value = asBundle.terminalFontDisplayName,
+ onValueChange = {},
+ readOnly = true,
+ label = "系统等宽字体",
+ useLabelAsPlaceholder = true,
+ modifier = Modifier.fillMaxWidth(),
+ trailingIcon = {
+ Row(
+ modifier = Modifier.padding(end = UiSpacing.Medium),
+ ) {
+ if (asBundle.terminalFontDisplayName.isNotBlank()) {
+ IconButton(
+ onClick = {
+ scope.launch {
+ val cleared = withContext(Dispatchers.IO) {
+ clearTerminalFont(context)
+ }
+ asBundle = asBundle.copy(
+ terminalFontDisplayName = ""
+ )
+ snackbar.show(
+ if (cleared) "已恢复默认终端字体"
+ else "当前没有可清除的自定义字体"
+ )
+ }
+ },
+ ) {
+ Icon(
+ Icons.Rounded.Clear,
+ contentDescription = "清空",
+ )
+ }
+ }
+ IconButton(onClick = terminalFontPicker.pick) {
+ Icon(
+ Icons.Rounded.FileOpen,
+ contentDescription = "选择字体",
+ )
+ }
+ }
+ },
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
index 04456bc..0efb137 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
@@ -53,6 +53,12 @@ fun StreamScreen(activity: StreamActivity) {
}
}
+ DisposableEffect(activity) {
+ onDispose {
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+
LaunchedEffect(
activity, isInPip,
currentSession?.width, currentSession?.height,
@@ -112,11 +118,20 @@ fun StreamScreen(activity: StreamActivity) {
onBack = activity::finish,
isInPip = isInPip,
onVideoSizeChanged = { width, height ->
- // 只在全屏时跟随视频方向
- if (!isInPip)
+ if (!isInPip) {
activity.requestedOrientation =
- if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
- else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ if (width >= height) {
+ if (asBundle.fullscreenControlIgnoreSystemRotationLock)
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ else
+ ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
+ } else {
+ if (asBundle.fullscreenControlIgnoreSystemRotationLock)
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
+ else
+ ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
+ }
+ }
},
onVideoBoundsInWindowChanged = {
// 记录下一次进入 PiP 时可用的 sourceRectHint
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt
index e74241a..6a39a36 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt
@@ -1,28 +1,30 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
+import android.graphics.Typeface
+import android.util.Log
+import android.view.KeyEvent
import android.view.MotionEvent
-import android.view.ScaleGestureDetector
-import android.view.ViewConfiguration
-import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -30,31 +32,40 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
+import com.termux.terminal.KeyHandler
+import com.termux.terminal.TerminalSession
+import com.termux.terminal.TextStyle
+import com.termux.view.TerminalView
+import com.termux.view.TerminalViewClient
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
+import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem
-import io.github.miuzarte.scrcpyforandroid.widgets.TerminalInputView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
-import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopupColumn
@@ -69,19 +80,33 @@ import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
+import java.io.File
import java.nio.charset.StandardCharsets
import kotlin.math.max
import kotlin.math.roundToInt
-import android.view.KeyEvent as AndroidKeyEvent
private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f
private const val MIN_TERMINAL_FONT_SIZE_SP = 1f
private const val MAX_TERMINAL_FONT_SIZE_SP = 32f
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
+private const val LOG_TAG = "TerminalScreen"
+private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
+
+private fun terminalFontFile(context: android.content.Context): File {
+ return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
+}
+
+private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
+ val file = terminalFontFile(context)
+ if (!file.exists()) return null
+ return runCatching { Typeface.createFromFile(file) }.getOrNull()
+}
@Composable
fun TerminalScreen(
bottomInnerPadding: Dp,
+ isActive: Boolean,
+ onTerminalGestureLockChanged: (Boolean) -> Unit,
) {
val context = LocalContext.current
val snackbar = LocalSnackbarController.current
@@ -126,7 +151,7 @@ fun TerminalScreen(
},
)
PopupMenuItem(
- text = "清空输出",
+ text = "清屏",
optionSize = 2,
index = 1,
onSelectedIndexChange = {
@@ -150,6 +175,8 @@ fun TerminalScreen(
TerminalPage(
contentPadding = pagePadding,
bottomInnerPadding = bottomInnerPadding,
+ isActive = isActive,
+ onTerminalGestureLockChanged = onTerminalGestureLockChanged,
output = output,
onOutputChange = { output = it },
)
@@ -173,104 +200,133 @@ fun TerminalScreen(
private fun TerminalPage(
contentPadding: PaddingValues,
bottomInnerPadding: Dp,
+ isActive: Boolean,
+ onTerminalGestureLockChanged: (Boolean) -> Unit,
output: String,
onOutputChange: (String) -> Unit,
) {
val context = LocalContext.current
- val snackbar = LocalSnackbarController.current
val density = LocalDensity.current
+ val snackbar = LocalSnackbarController.current
+ val asBundleShared by appSettings.bundleState.collectAsState()
val uiScope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
- val touchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop }
- val outputScrollState = rememberScrollState()
- var outputBuffer by rememberSaveable { mutableStateOf(output) }
- var terminalFontSizeSp by rememberSaveable { mutableFloatStateOf(DEFAULT_TERMINAL_FONT_SIZE_SP) }
+ val shellWriteMutex = remember { Mutex() }
+ var terminalFontSizeSp by rememberSaveable(asBundleShared.terminalFontSizeSp) {
+ mutableFloatStateOf(asBundleShared.terminalFontSizeSp)
+ }
var shellReady by remember { mutableStateOf(false) }
var shellConnecting by remember { mutableStateOf(false) }
var shellStream by remember { mutableStateOf(null) }
- var terminalInputView by remember { mutableStateOf(null) }
- var showKeyboardWhenReady by remember { mutableStateOf(false) }
- var pinchInProgress by remember { mutableStateOf(false) }
- var touchDownX by remember { mutableFloatStateOf(0f) }
- var touchDownY by remember { mutableFloatStateOf(0f) }
- var tapPending by remember { mutableStateOf(false) }
+ var terminalView by remember { mutableStateOf(null) }
val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() }
+ var ctrlLatched by rememberSaveable { mutableStateOf(false) }
+ var altLatched by rememberSaveable { mutableStateOf(false) }
+ var pendingLatchedConsume by remember { mutableStateOf(false) }
+ var pinchGestureLock by remember { mutableStateOf(false) }
+ var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) }
+ val sessionHolder = remember { arrayOfNulls(1) }
+ val terminalSurfaceColorArgb = colorScheme.surface.toArgb()
+ val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb()
+ val terminalCursorColorArgb =
+ if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt()
+ else 0xff000000.toInt()
- fun commitOutput(text: String) {
- outputBuffer = text
- onOutputChange(text)
- }
-
- fun appendRawOutput(text: String) {
- if (text.isEmpty()) {
- return
+ LaunchedEffect(asBundleShared.terminalFontSizeSp) {
+ if (terminalFontSizeSp != asBundleShared.terminalFontSizeSp) {
+ terminalFontSizeSp = asBundleShared.terminalFontSizeSp
}
- val builder = StringBuilder(outputBuffer)
- text.forEach { ch ->
- when (ch) {
- '\b',
- '\u007F' -> if (builder.isNotEmpty()) builder.deleteCharAt(builder.lastIndex)
-
- else -> builder.append(ch)
- }
- }
- commitOutput(builder.toString())
}
- fun appendOutputLine(text: String) {
- commitOutput(
- buildString {
- append(outputBuffer)
- if (isNotEmpty() && !endsWith("\n")) {
- append("\n")
- }
- append(text)
- if (!endsWith("\n")) {
- append("\n")
- }
- }
- )
+ fun extractTranscript(session: TerminalSession): String {
+ val screen = session.emulator.getScreen()
+ return screen.getSelectedText(
+ selX1 = 0,
+ selY1 = -screen.activeTranscriptRows,
+ selX2 = session.emulator.mColumns,
+ selY2 = session.emulator.mRows - 1,
+ joinBackLines = false,
+ joinFullLines = false,
+ ).trim('\n')
}
- fun writeToShell(text: String) {
+ fun syncOutput() {
+ val session = sessionHolder[0] ?: return
+ onOutputChange(extractTranscript(session))
+ terminalView?.onScreenUpdated()
+ }
+
+ fun writeBytesToShell(
+ data: ByteArray,
+ offset: Int,
+ count: Int,
+ ) {
val stream = shellStream
if (stream == null || !shellReady || stream.closed) {
- snackbar.show("终端会话尚未就绪")
return
}
+ val payload = data.copyOfRange(offset, offset + count)
taskScope.launch {
val result = runCatching {
- stream.outputStream.write(text.toByteArray(StandardCharsets.UTF_8))
- stream.outputStream.flush()
+ shellWriteMutex.withLock {
+ stream.outputStream.write(payload)
+ stream.outputStream.flush()
+ }
}
withContext(Dispatchers.Main) {
result.onFailure { error ->
- appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}")
- snackbar.show("终端输入失败")
+ snackbar.show("终端输入失败: ${error.message ?: error.javaClass.simpleName}")
}
}
}
}
- fun mapKeyEventToShellText(event: AndroidKeyEvent): String? {
- if (event.action != AndroidKeyEvent.ACTION_DOWN) {
- return ""
- }
- return when (event.keyCode) {
- AndroidKeyEvent.KEYCODE_ENTER -> "\n"
- AndroidKeyEvent.KEYCODE_DEL -> "\b"
- AndroidKeyEvent.KEYCODE_TAB -> "\t"
- AndroidKeyEvent.KEYCODE_DPAD_UP -> "\u001B[A"
- AndroidKeyEvent.KEYCODE_DPAD_DOWN -> "\u001B[B"
- AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> "\u001B[C"
- AndroidKeyEvent.KEYCODE_DPAD_LEFT -> "\u001B[D"
- else -> null
+ fun writeClipboardToShell() {
+ val text = LocalInputService.getClipboardText(context)
+ if (!text.isNullOrBlank()) {
+ val bytes = text.toByteArray(StandardCharsets.UTF_8)
+ writeBytesToShell(bytes, 0, bytes.size)
}
}
- fun showKeyboard() {
- val inputView = terminalInputView ?: return
- LocalInputService.showSoftKeyboard(inputView)
+ val terminalSession = remember {
+ TerminalSession(
+ shellWriter = ::writeBytesToShell,
+ onScreenUpdated = ::syncOutput,
+ onCopyTextToClipboardRequested = { text ->
+ LocalInputService.setClipboardText(context, text)
+ uiScope.launch {
+ snackbar.show("已复制到剪贴板")
+ }
+ },
+ onPasteTextFromClipboardRequested = ::writeClipboardToShell,
+ onBellRequested = {},
+ )
+ }
+ sessionHolder[0] = terminalSession
+
+ fun applyTerminalThemeColors() {
+ val colors = terminalSession.emulator.mColors.mCurrentColors
+ colors[TextStyle.COLOR_INDEX_BACKGROUND] = terminalSurfaceColorArgb
+ colors[TextStyle.COLOR_INDEX_FOREGROUND] = terminalOnSurfaceColorArgb
+ colors[TextStyle.COLOR_INDEX_CURSOR] = terminalCursorColorArgb
+ }
+
+ fun updateTerminalFontSize(newValue: Float) {
+ val clamped = newValue.coerceIn(MIN_TERMINAL_FONT_SIZE_SP, MAX_TERMINAL_FONT_SIZE_SP)
+ if (clamped == terminalFontSizeSp) {
+ return
+ }
+ terminalFontSizeSp = clamped
+ terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
+ uiScope.launch {
+ val latest = appSettings.bundleState.value
+ if (latest.terminalFontSizeSp != clamped) {
+ appSettings.updateBundle {
+ it.copy(terminalFontSizeSp = clamped)
+ }
+ }
+ }
}
fun showFontSizeSnackbar() {
@@ -283,68 +339,46 @@ private fun TerminalPage(
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
- terminalFontSizeSp = DEFAULT_TERMINAL_FONT_SIZE_SP
+ updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP)
}
}
}
- val scaleGestureDetector = remember {
- var scaleAccumulator = 1f
- ScaleGestureDetector(
- context,
- object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
- override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
- pinchInProgress = true
- scaleAccumulator = 1f
- return true
- }
-
- override fun onScale(detector: ScaleGestureDetector): Boolean {
- scaleAccumulator *= detector.scaleFactor
- var updated = false
- while (scaleAccumulator >= FONT_SCALE_STEP_THRESHOLD) {
- val nextSize = (terminalFontSizeSp + 1f)
- .coerceAtMost(MAX_TERMINAL_FONT_SIZE_SP)
- if (nextSize == terminalFontSizeSp) {
- scaleAccumulator = 1f
- break
- }
- terminalFontSizeSp = nextSize
- scaleAccumulator /= FONT_SCALE_STEP_THRESHOLD
- updated = true
- }
- while (scaleAccumulator <= 1f / FONT_SCALE_STEP_THRESHOLD) {
- val nextSize = (terminalFontSizeSp - 1f)
- .coerceAtLeast(MIN_TERMINAL_FONT_SIZE_SP)
- if (nextSize == terminalFontSizeSp) {
- scaleAccumulator = 1f
- break
- }
- terminalFontSizeSp = nextSize
- scaleAccumulator *= FONT_SCALE_STEP_THRESHOLD
- updated = true
- }
- if (updated) {
- showFontSizeSnackbar()
- }
- return true
- }
-
- override fun onScaleEnd(detector: ScaleGestureDetector) {
- pinchInProgress = false
- }
- },
- )
+ fun handleTerminalTouchInterception(
+ view: TerminalView,
+ event: MotionEvent,
+ ): Boolean {
+ val shouldLock = event.pointerCount > 1 || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN
+ if (shouldLock) {
+ view.parent?.requestDisallowInterceptTouchEvent(true)
+ if (!pinchGestureLock) {
+ pinchGestureLock = true
+ onTerminalGestureLockChanged(true)
+ }
+ }
+ val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP ||
+ event.actionMasked == MotionEvent.ACTION_CANCEL ||
+ (event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2)
+ if (shouldUnlock) {
+ view.parent?.requestDisallowInterceptTouchEvent(false)
+ if (pinchGestureLock) {
+ pinchGestureLock = false
+ onTerminalGestureLockChanged(false)
+ }
+ }
+ return false
}
fun openShellSession(showKeyboardAfterConnect: Boolean) {
- if (showKeyboardAfterConnect) {
- showKeyboardWhenReady = true
- }
if (shellStream != null || shellConnecting) {
if (shellReady && showKeyboardAfterConnect) {
- showKeyboard()
- showKeyboardWhenReady = false
+ terminalView?.requestFocusFromTouch()
+ terminalView?.requestFocus()
+ terminalView?.post {
+ terminalView?.requestFocusFromTouch()
+ terminalView?.requestFocus()
+ terminalView?.let(LocalInputService::showSoftKeyboard)
+ }
}
return
}
@@ -357,9 +391,7 @@ private fun TerminalPage(
withContext(Dispatchers.Main) {
shellConnecting = false
shellReady = false
- showKeyboardWhenReady = false
- appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}")
- snackbar.show("终端会话创建失败")
+ snackbar.show("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}")
}
return@launch
}
@@ -368,9 +400,14 @@ private fun TerminalPage(
shellStream = stream
shellReady = true
shellConnecting = false
- if (showKeyboardWhenReady) {
- showKeyboard()
- showKeyboardWhenReady = false
+ if (showKeyboardAfterConnect) {
+ terminalView?.requestFocusFromTouch()
+ terminalView?.requestFocus()
+ terminalView?.post {
+ terminalView?.requestFocusFromTouch()
+ terminalView?.requestFocus()
+ terminalView?.let(LocalInputService::showSoftKeyboard)
+ }
}
}
@@ -381,14 +418,13 @@ private fun TerminalPage(
if (count <= 0) {
break
}
- val chunk = String(buffer, 0, count, StandardCharsets.UTF_8)
withContext(Dispatchers.Main) {
- appendRawOutput(chunk)
+ terminalSession.append(buffer, count)
}
}
} catch (error: Throwable) {
withContext(Dispatchers.Main) {
- appendOutputLine("错误: ${error.message ?: error.javaClass.simpleName}")
+ snackbar.show("终端输出失败: ${error.message ?: error.javaClass.simpleName}")
}
} finally {
runCatching { stream.close() }
@@ -398,30 +434,223 @@ private fun TerminalPage(
}
shellReady = false
shellConnecting = false
- showKeyboardWhenReady = false
}
}
}
}
+ fun consumeLatchedModifiers(): Int {
+ var modifiers = 0
+ if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL
+ if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT
+ ctrlLatched = false
+ altLatched = false
+ pendingLatchedConsume = false
+ return modifiers
+ }
+
+ fun consumeLatchedVisualState() {
+ ctrlLatched = false
+ altLatched = false
+ pendingLatchedConsume = false
+ }
+
+ fun applyCtrlModifier(ch: Char): Char {
+ return when (ch) {
+ in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar()
+ in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar()
+ ' ' -> 0.toChar()
+ '[' -> 27.toChar()
+ '\\' -> 28.toChar()
+ ']' -> 29.toChar()
+ '^' -> 30.toChar()
+ '_', '/' -> 31.toChar()
+ else -> ch
+ }
+ }
+
+ fun writeLiteralKey(text: String) {
+ var payload = text
+ val modifiers = consumeLatchedModifiers()
+ if ((modifiers and KeyHandler.KEYMOD_CTRL) != 0) {
+ payload = payload.map(::applyCtrlModifier).joinToString(separator = "")
+ }
+ if ((modifiers and KeyHandler.KEYMOD_ALT) != 0) {
+ payload = "\u001B$payload"
+ }
+ val bytes = payload.toByteArray(StandardCharsets.UTF_8)
+ writeBytesToShell(bytes, 0, bytes.size)
+ }
+
+ fun writeSpecialKey(keyCode: Int) {
+ val modifiers = consumeLatchedModifiers()
+ val sequence = KeyHandler.getCode(
+ keyCode,
+ modifiers,
+ terminalSession.emulator.isCursorKeysApplicationMode(),
+ terminalSession.emulator.isKeypadApplicationMode(),
+ ) ?: return
+ val bytes = sequence.toByteArray(StandardCharsets.UTF_8)
+ writeBytesToShell(bytes, 0, bytes.size)
+ }
+
+ val terminalViewClient = remember {
+ object : TerminalViewClient {
+ override fun onScale(scale: Float): Float {
+ when {
+ scale >= FONT_SCALE_STEP_THRESHOLD -> {
+ updateTerminalFontSize(terminalFontSizeSp + 1f)
+ showFontSizeSnackbar()
+ return 1f
+ }
+
+ scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
+ updateTerminalFontSize(terminalFontSizeSp - 1f)
+ showFontSizeSnackbar()
+ return 1f
+ }
+ }
+ return scale
+ }
+
+ override fun onSingleTapUp(e: MotionEvent) {
+ openShellSession(showKeyboardAfterConnect = true)
+ }
+
+ override fun shouldBackButtonBeMappedToEscape(): Boolean = false
+
+ override fun shouldEnforceCharBasedInput(): Boolean = false
+
+ override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
+
+ override fun isTerminalViewSelected(): Boolean = true
+
+ override fun copyModeChanged(copyMode: Boolean) = Unit
+
+ override fun onKeyDown(
+ keyCode: Int,
+ e: KeyEvent,
+ session: TerminalSession,
+ ): Boolean {
+ if (e.action == KeyEvent.ACTION_DOWN && (ctrlLatched || altLatched)) {
+ pendingLatchedConsume = true
+ }
+ return false
+ }
+
+ override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean {
+ if (e.action == KeyEvent.ACTION_UP && pendingLatchedConsume) {
+ consumeLatchedVisualState()
+ }
+ return false
+ }
+
+ override fun onLongPress(event: MotionEvent): Boolean = false
+
+ override fun readControlKey(): Boolean = ctrlLatched
+
+ override fun readAltKey(): Boolean = altLatched
+
+ override fun readShiftKey(): Boolean = false
+
+ override fun readFnKey(): Boolean = false
+
+ override fun onCodePoint(
+ codePoint: Int,
+ ctrlDown: Boolean,
+ session: TerminalSession,
+ ): Boolean {
+ if (ctrlLatched || altLatched || pendingLatchedConsume) {
+ consumeLatchedVisualState()
+ }
+ return false
+ }
+
+ override fun onEmulatorSet() {
+ terminalView?.setTerminalCursorBlinkerRate(500)
+ terminalView?.setTerminalCursorBlinkerState(
+ start = true,
+ startOnlyIfCursorEnabled = true
+ )
+ syncOutput()
+ }
+
+ override fun logError(tag: String?, message: String?) {
+ Log.e(tag ?: LOG_TAG, message.orEmpty())
+ }
+
+ override fun logWarn(tag: String?, message: String?) {
+ Log.w(tag ?: LOG_TAG, message.orEmpty())
+ }
+
+ override fun logInfo(tag: String?, message: String?) {
+ Log.i(tag ?: LOG_TAG, message.orEmpty())
+ }
+
+ override fun logDebug(tag: String?, message: String?) {
+ Log.d(tag ?: LOG_TAG, message.orEmpty())
+ }
+
+ override fun logVerbose(tag: String?, message: String?) {
+ Log.v(tag ?: LOG_TAG, message.orEmpty())
+ }
+
+ override fun logStackTraceWithMessage(
+ tag: String?,
+ message: String?,
+ e: Exception?,
+ ) {
+ Log.e(tag ?: LOG_TAG, message, e)
+ }
+
+ override fun logStackTrace(tag: String?, e: Exception?) {
+ Log.e(tag ?: LOG_TAG, e?.message, e)
+ }
+ }
+ }
+
LaunchedEffect(output) {
- if (output != outputBuffer) {
- outputBuffer = output
+ if (output.isEmpty() && extractTranscript(terminalSession).isNotEmpty()) {
+ terminalSession.reset()
+ applyTerminalThemeColors()
+ terminalView?.mEmulator = terminalSession.emulator
+ terminalView?.onScreenUpdated()
+ syncOutput()
}
}
- LaunchedEffect(outputBuffer) {
- outputScrollState.scrollTo(outputScrollState.maxValue)
+ LaunchedEffect(isActive) {
+ if (!isActive) {
+ onTerminalGestureLockChanged(false)
+ return@LaunchedEffect
+ }
+ customTypeface = loadTerminalTypeface(context)
+ if (shellStream == null && !shellConnecting) {
+ val connected = runCatching { NativeAdbService.isConnected() }.getOrDefault(false)
+ if (connected) {
+ openShellSession(showKeyboardAfterConnect = false)
+ }
+ }
}
- LaunchedEffect(imeBottomDp.value) {
- if (imeBottomDp > 0.dp) {
- outputScrollState.scrollTo(outputScrollState.maxValue)
- }
+ LaunchedEffect(colorScheme.surface, colorScheme.onSurface) {
+ applyTerminalThemeColors()
+ terminalView?.onScreenUpdated()
+ syncOutput()
}
DisposableEffect(Unit) {
onDispose {
+ val latestFontSize = terminalFontSizeSp
+ onTerminalGestureLockChanged(false)
+ taskScope.launch {
+ val latest = appSettings.bundleState.value
+ if (latest.terminalFontSizeSp != latestFontSize) {
+ appSettings.updateBundle {
+ it.copy(terminalFontSizeSp = latestFontSize)
+ }
+ }
+ }
runCatching { shellStream?.close() }
taskScope.cancel()
}
@@ -435,141 +664,142 @@ private fun TerminalPage(
start = UiSpacing.PageHorizontal,
top = UiSpacing.PageHorizontal,
end = UiSpacing.PageVertical,
- bottom = UiSpacing.PageVertical
- + max(bottomInnerPadding.value, imeBottomDp.value).dp,
+ bottom = UiSpacing.PageVertical +
+ max(bottomInnerPadding.value, imeBottomDp.value).dp,
),
) {
- Card(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- ) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(outputScrollState)
- .padding(16.dp),
- verticalArrangement = Arrangement.Bottom,
- ) {
- Text(
- text = outputBuffer.ifBlank {
- """
- 可用缩放手势调整字体大小
- 轻触以连接终端
- """.trimIndent()
- },
- modifier = Modifier.fillMaxWidth(),
- fontSize = terminalFontSizeSp.sp,
- color =
- if (outputBuffer.isBlank()) colorScheme.onSurfaceVariantSummary
- else colorScheme.onSurface,
+ AndroidView(
+ factory = { viewContext ->
+ TerminalView(viewContext, null).apply {
+ isFocusable = true
+ isFocusableInTouchMode = true
+ requestFocus()
+ setTerminalViewClient(terminalViewClient)
+ setIsTerminalViewKeyLoggingEnabled(false)
+ setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
+ setTypeface(customTypeface ?: Typeface.MONOSPACE)
+ attachSession(terminalSession)
+ applyTerminalThemeColors()
+ setTerminalCursorBlinkerRate(500)
+ setTerminalCursorBlinkerState(
+ start = true,
+ startOnlyIfCursorEnabled = true,
)
+ setOnTouchListener { _, event ->
+ handleTerminalTouchInterception(this, event)
+ }
+ terminalView = this
}
+ },
+ update = {
+ terminalView = it
+ it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
+ it.setTypeface(customTypeface ?: Typeface.MONOSPACE)
+ if (it.currentSession !== terminalSession) {
+ it.attachSession(terminalSession)
+ }
+ it.setOnTouchListener { _, event ->
+ handleTerminalTouchInterception(it, event)
+ }
+ applyTerminalThemeColors()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ )
- AndroidView(
- factory = {
- TerminalInputView(it).apply {
- alpha = 0f
- setInputEnabled(true)
- setOnTouchListener { _, event ->
- scaleGestureDetector.onTouchEvent(event)
- when {
- event.actionMasked == MotionEvent.ACTION_DOWN -> {
- pinchInProgress = false
- tapPending = true
- touchDownX = event.x
- touchDownY = event.y
- parent?.requestDisallowInterceptTouchEvent(false)
- true
- }
-
- event.pointerCount >= 2 -> {
- pinchInProgress = true
- tapPending = false
- parent?.requestDisallowInterceptTouchEvent(true)
- true
- }
-
- event.actionMasked == MotionEvent.ACTION_MOVE -> {
- val movedFar =
- kotlin.math.abs(event.x - touchDownX) > touchSlop ||
- kotlin.math.abs(event.y - touchDownY) > touchSlop
- if (movedFar) {
- tapPending = false
- }
- if (pinchInProgress) {
- parent?.requestDisallowInterceptTouchEvent(true)
- } else {
- parent?.requestDisallowInterceptTouchEvent(false)
- }
- true
- }
-
- event.actionMasked == MotionEvent.ACTION_UP -> {
- if (!pinchInProgress && tapPending) {
- performClick()
- openShellSession(showKeyboardAfterConnect = true)
- }
- pinchInProgress = false
- tapPending = false
- parent?.requestDisallowInterceptTouchEvent(false)
- true
- }
-
- event.actionMasked == MotionEvent.ACTION_UP ||
- event.actionMasked == MotionEvent.ACTION_CANCEL -> {
- pinchInProgress = false
- tapPending = false
- parent?.requestDisallowInterceptTouchEvent(false)
- true
- }
-
- else -> true
- }
- }
- inputCallbacks = object : TerminalInputView.InputCallbacks {
- override fun handleCommitText(text: CharSequence): Boolean {
- if (text.isEmpty()) {
- return true
- }
- writeToShell(text.toString())
- return true
- }
-
- override fun handleDeleteSurroundingText(
- beforeLength: Int,
- afterLength: Int,
- ): Boolean {
- val deleteCount = beforeLength.coerceAtLeast(1)
- writeToShell("\b".repeat(deleteCount))
- return true
- }
-
- override fun handleKeyEvent(event: AndroidKeyEvent): Boolean {
- val shellText = mapKeyEventToShellText(event) ?: return false
- if (shellText.isNotEmpty()) {
- writeToShell(shellText)
- }
- return true
- }
- }
- terminalInputView = this
- }
- },
- update = {
- terminalInputView = it
- if (showKeyboardWhenReady && shellReady) {
- LocalInputService.showSoftKeyboard(it)
- showKeyboardWhenReady = false
- }
- },
- modifier = Modifier.fillMaxSize(),
- )
- }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ TerminalExtraKeyButton(
+ label = "ESC",
+ modifier = Modifier.weight(1f),
+ ) { writeLiteralKey("\u001B") }
+ TerminalExtraKeyButton(
+ label = "/",
+ modifier = Modifier.weight(1f),
+ ) { writeLiteralKey("/") }
+ TerminalExtraKeyButton(
+ label = "-",
+ modifier = Modifier.weight(1f),
+ ) { writeLiteralKey("-") }
+ TerminalExtraKeyButton(
+ label = "HOME",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME) }
+ TerminalExtraKeyButton(
+ label = "↑",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP) }
+ TerminalExtraKeyButton(
+ label = "END",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_MOVE_END) }
+ TerminalExtraKeyButton(
+ label = "PGUP",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP) }
}
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ TerminalExtraKeyButton(
+ label = "TAB",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_TAB) }
+ TerminalExtraKeyButton(
+ label = "CTRL",
+ active = ctrlLatched,
+ modifier = Modifier.weight(1f),
+ ) { ctrlLatched = !ctrlLatched }
+ TerminalExtraKeyButton(
+ label = "ALT",
+ active = altLatched,
+ modifier = Modifier.weight(1f),
+ ) { altLatched = !altLatched }
+ TerminalExtraKeyButton(
+ label = "←",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT) }
+ TerminalExtraKeyButton(
+ label = "↓",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN) }
+ TerminalExtraKeyButton(
+ label = "→",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT) }
+ TerminalExtraKeyButton(
+ label = "PGDN",
+ modifier = Modifier.weight(1f),
+ ) { writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN) }
+ }
+ }
+}
+
+@Composable
+private fun TerminalExtraKeyButton(
+ label: String,
+ modifier: Modifier = Modifier,
+ active: Boolean = false,
+ onClick: () -> Unit,
+) {
+ val content =
+ if (active) colorScheme.primary
+ else colorScheme.onSurface
+
+ Box(
+ modifier = modifier
+ .height(32.dp)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = content,
+ fontSize = 15.sp,
+ )
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt
index d014cd1..dc59fa2 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt
@@ -195,7 +195,7 @@ data class ClientOptions(
// var window: Boolean,
// --no-mouse-hover
- // var mouseHover: Boolean = true,
+ var mouseHover: Boolean = true,
// --audio-dup
var audioDup: Boolean = false, // to server
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
index 36cd324..5462a1f 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt
@@ -166,7 +166,11 @@ class Scrcpy(
}
// Create session info
- _currentSessionState.value = info.copy(legacyPaste = options.legacyPaste)
+ _currentSessionState.value = info.copy(
+ legacyPaste = options.legacyPaste,
+ mouseHover = options.mouseHover,
+ killAdbOnClose = options.killAdbOnClose,
+ )
isRunning = true
startClipboardSync()
@@ -297,6 +301,9 @@ class Scrcpy(
buttons = buttons,
)
+ suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) =
+ session.pressBackOrTurnScreenOn(action)
+
fun updateCurrentSessionSize(width: Int, height: Int) {
val current = _currentSessionState.value ?: return
if (current.width == width && current.height == height) return
@@ -1362,6 +1369,8 @@ class Scrcpy(
val audioCodecId: Int = 0,
val controlEnabled: Boolean,
val legacyPaste: Boolean = false,
+ val mouseHover: Boolean = true,
+ val killAdbOnClose: Boolean = false,
val host: String = "",
val port: Int = Defaults.ADB_PORT,
)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt
index 21d0c59..df2350b 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt
@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.util.Log
+import android.view.InputDevice
import android.view.MotionEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
@@ -24,13 +25,24 @@ class TouchEventHandler(
private val activePointerDevicePositions: LinkedHashMap>,
private val pointerLabels: LinkedHashMap,
private var nextPointerLabel: Int,
- private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
+ private val mouseHoverEnabled: Boolean,
+ private val onInjectTouch: suspend (
+ action: Int,
+ pointerId: Long,
+ x: Int,
+ y: Int,
+ pressure: Float,
+ actionButton: Int,
+ buttons: Int,
+ ) -> Unit,
+ private val onBackOrScreenOn: suspend (action: Int) -> Unit,
private val onActiveTouchCountChanged: (Int) -> Unit,
private val onActiveTouchDebugChanged: (String) -> Unit,
private val onNextPointerLabelChanged: (Int) -> Unit,
) {
companion object {
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
+ private const val POINTER_ID_MOUSE = -1L
}
private object UiMotionActions {
@@ -54,6 +66,10 @@ class TouchEventHandler(
val bounds = calculateContentBounds()
+ if (isMouseLikeEvent(event)) {
+ return handleMouseEvent(event, bounds)
+ }
+
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return handleCancelAction(bounds)
}
@@ -71,6 +87,14 @@ class TouchEventHandler(
return true
}
+ private fun isMouseLikeEvent(event: MotionEvent): Boolean {
+ return event.isFromSource(InputDevice.SOURCE_MOUSE) ||
+ event.actionMasked == MotionEvent.ACTION_HOVER_ENTER ||
+ event.actionMasked == MotionEvent.ACTION_HOVER_MOVE ||
+ event.actionMasked == MotionEvent.ACTION_HOVER_EXIT ||
+ event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ }
+
private data class ContentBounds(
val width: Float,
val height: Float,
@@ -149,13 +173,97 @@ class TouchEventHandler(
onActiveTouchDebugChanged(debug)
}
+ private fun handleMouseEvent(
+ event: MotionEvent,
+ bounds: ContentBounds,
+ ): Boolean {
+ val rawX = event.getX(0)
+ val rawY = event.getY(0)
+ val (x, y) = mapToDevice(rawX, rawY, bounds)
+ val pressure = event.getPressure(0).coerceIn(0f, 1f)
+ val buttons = event.buttonState
+ val actionButton = event.actionButton
+ val isHoverMotion = when (event.actionMasked) {
+ MotionEvent.ACTION_HOVER_ENTER,
+ MotionEvent.ACTION_HOVER_MOVE,
+ MotionEvent.ACTION_HOVER_EXIT -> true
+
+ MotionEvent.ACTION_MOVE -> buttons == 0
+ else -> false
+ }
+
+ if (!mouseHoverEnabled && isHoverMotion) {
+ return true
+ }
+
+ if (actionButton == MotionEvent.BUTTON_SECONDARY ||
+ buttons and MotionEvent.BUTTON_SECONDARY != 0
+ ) {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_BUTTON_PRESS -> {
+ coroutineScope.launch {
+ runCatching {
+ onBackOrScreenOn(0)
+ }.onFailure { e ->
+ Log.w(FULLSCREEN_TOUCH_LOG_TAG, "mouse secondary down failed", e)
+ }
+ }
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_BUTTON_RELEASE -> {
+ coroutineScope.launch {
+ runCatching {
+ onBackOrScreenOn(1)
+ }.onFailure { e ->
+ Log.w(FULLSCREEN_TOUCH_LOG_TAG, "mouse secondary up failed", e)
+ }
+ }
+ }
+ }
+ return true
+ }
+
+ val injectAction = when (event.actionMasked) {
+ MotionEvent.ACTION_HOVER_ENTER -> MotionEvent.ACTION_HOVER_ENTER
+ MotionEvent.ACTION_HOVER_MOVE -> MotionEvent.ACTION_HOVER_MOVE
+ MotionEvent.ACTION_HOVER_EXIT -> MotionEvent.ACTION_HOVER_EXIT
+ MotionEvent.ACTION_DOWN -> MotionEvent.ACTION_DOWN
+ MotionEvent.ACTION_UP -> MotionEvent.ACTION_UP
+ MotionEvent.ACTION_MOVE -> MotionEvent.ACTION_MOVE
+
+ MotionEvent.ACTION_BUTTON_PRESS,
+ MotionEvent.ACTION_BUTTON_RELEASE -> return true
+
+ else -> return true
+ }
+
+ coroutineScope.launch {
+ runCatching {
+ onInjectTouch(
+ injectAction,
+ POINTER_ID_MOUSE,
+ x,
+ y,
+ pressure,
+ actionButton,
+ buttons,
+ )
+ }.onFailure { e ->
+ Log.w(FULLSCREEN_TOUCH_LOG_TAG, "handleMouseEvent failed", e)
+ }
+ }
+ return true
+ }
+
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
runCatching {
- onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
+ onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0, 0)
}.onFailure { e ->
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
}
@@ -222,7 +330,7 @@ class TouchEventHandler(
justPressedPointerIds += pointerId
coroutineScope.launch {
runCatching {
- onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
+ onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
@@ -252,7 +360,7 @@ class TouchEventHandler(
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
runCatching {
- onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
+ onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt
index fbce077..a93e552 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt
@@ -29,9 +29,9 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
) : Parcelable {
}
- private val bundleFields = arrayOf(
- bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
- bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
+ private val bundleFields = arrayOf>(
+ bundleField(RSA_PRIVATE_KEY) { it.rsaPrivateKey },
+ bundleField(RSA_PUBLIC_KEY_X509) { it.rsaPublicKeyX509 },
)
val bundleState: StateFlow = createBundleState(::bundleFromPreferences)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
index 34e404f..028b0c7 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
@@ -13,14 +13,68 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AppSettings(context: Context) : Settings(context, "AppSettings") {
+ enum class FullscreenVirtualButtonDock(
+ val rawValue: String,
+ val isFixed: Boolean,
+ val directionLabel: String,
+ ) {
+ FOLLOW_TOP("FOLLOW_TOP", false, "上方"),
+ FOLLOW_BOTTOM("FOLLOW_BOTTOM", false, "下方"),
+ FOLLOW_LEFT("FOLLOW_LEFT", false, "左侧"),
+ FOLLOW_RIGHT("FOLLOW_RIGHT", false, "右侧"),
+ FIXED_TOP("FIXED_TOP", true, "上方"),
+ FIXED_BOTTOM("FIXED_BOTTOM", true, "下方"),
+ FIXED_LEFT("FIXED_LEFT", true, "左侧"),
+ FIXED_RIGHT("FIXED_RIGHT", true, "右侧");
+
+ fun toStoredValue(): String = rawValue
+
+ val summary: String
+ get() = "${if (!isFixed) "跟随" else "固定"}显示在屏幕$directionLabel"
+
+ val modeIndex: Int
+ get() = if (!isFixed) 0 else 1
+
+ val directionIndex: Int
+ get() = when (this) {
+ FOLLOW_TOP, FIXED_TOP -> 0
+ FOLLOW_BOTTOM, FIXED_BOTTOM -> 1
+ FOLLOW_LEFT, FIXED_LEFT -> 2
+ FOLLOW_RIGHT, FIXED_RIGHT -> 3
+ }
+
+ companion object {
+ val modeItems = listOf("跟随", "固定")
+ val directionItems = listOf("上方", "下方", "左侧", "右侧")
+
+ fun fromBundle(bundle: Bundle) =
+ entries.firstOrNull { it.rawValue == bundle.fullscreenVirtualButtonDock }
+ ?: FOLLOW_BOTTOM
+
+ fun fromStoredValue(value: String) =
+ entries.firstOrNull { it.rawValue == value }
+ ?: FOLLOW_BOTTOM
+
+ fun fromModeAndDirection(modeIndex: Int, directionIndex: Int) =
+ when (directionIndex) {
+ 0 -> if (modeIndex == 0) FOLLOW_TOP else FIXED_TOP
+ 1 -> if (modeIndex == 0) FOLLOW_BOTTOM else FIXED_BOTTOM
+ 2 -> if (modeIndex == 0) FOLLOW_LEFT else FIXED_LEFT
+ 3 -> if (modeIndex == 0) FOLLOW_RIGHT else FIXED_RIGHT
+ else -> if (modeIndex == 0) FOLLOW_BOTTOM else FIXED_BOTTOM
+ }
+ }
+ }
+
companion object {
+ // Theme
val THEME_BASE_INDEX = Pair(
intPreferencesKey("theme_base_index"),
- 0
+ 0,
)
val MONET = Pair(
booleanPreferencesKey("monet"),
- false
+ false,
)
val BLUR = Pair(
booleanPreferencesKey("blur"),
@@ -38,29 +92,15 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("smooth_corner"),
false,
)
+
+ // Scrcpy
val LOW_LATENCY = Pair(
booleanPreferencesKey("low_latency"),
false,
)
val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"),
- false
- )
- val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
- booleanPreferencesKey("show_fullscreen_virtual_buttons"),
- false
- )
- val SHOW_FULLSCREEN_FLOATING_BUTTON = Pair(
- booleanPreferencesKey("show_fullscreen_floating_button"),
- true
- )
- val FULLSCREEN_FLOATING_BUTTON_X_FRACTION = Pair(
- floatPreferencesKey("fullscreen_floating_button_x_fraction"),
- 0.84f
- )
- val FULLSCREEN_FLOATING_BUTTON_Y_FRACTION = Pair(
- floatPreferencesKey("fullscreen_floating_button_y_fraction"),
- 0.72f
+ false,
)
val HIDE_SIMPLE_CONFIG_ITEMS = Pair(
booleanPreferencesKey("hide_simple_config_items"),
@@ -68,11 +108,56 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
)
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
- 1080 / 3
+ 1080 / 3,
+ )
+ val FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK = Pair(
+ booleanPreferencesKey("fullscreen_control_ignore_system_rotation_lock"),
+ true,
+ )
+ val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
+ booleanPreferencesKey("show_fullscreen_virtual_buttons"),
+ true,
+ )
+ val FULLSCREEN_VIRTUAL_BUTTON_HEIGHT_DP = Pair(
+ intPreferencesKey("fullscreen_virtual_button_height_dp"),
+ 16,
+ )
+ val FULLSCREEN_VIRTUAL_BUTTON_DOCK = Pair(
+ stringPreferencesKey("fullscreen_virtual_button_dock"),
+ FullscreenVirtualButtonDock.FIXED_BOTTOM.toStoredValue(),
+ )
+ val SHOW_FULLSCREEN_FLOATING_BUTTON = Pair(
+ booleanPreferencesKey("show_fullscreen_floating_button"),
+ true,
+ )
+ val FULLSCREEN_FLOATING_BUTTON_SIZE_DP = Pair(
+ intPreferencesKey("fullscreen_floating_button_size_dp"),
+ 48,
+ )
+ val FULLSCREEN_FLOATING_BUTTON_BACKGROUND_ALPHA_PERCENT = Pair(
+ intPreferencesKey("fullscreen_floating_button_background_alpha_percent"),
+ 25,
+ )
+ val FULLSCREEN_FLOATING_BUTTON_RING_ALPHA_PERCENT = Pair(
+ intPreferencesKey("fullscreen_floating_button_ring_alpha_percent"),
+ 100,
+ )
+ val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair(
+ booleanPreferencesKey("realtime_clipboard_sync_to_device"),
+ true,
+ )
+
+ val FULLSCREEN_FLOATING_BUTTON_X_FRACTION = Pair(
+ floatPreferencesKey("fullscreen_floating_button_x_fraction"),
+ 0.84f,
+ )
+ val FULLSCREEN_FLOATING_BUTTON_Y_FRACTION = Pair(
+ floatPreferencesKey("fullscreen_floating_button_y_fraction"),
+ 0.72f,
)
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),
- true
+ true,
)
val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("virtual_buttons_layout"),
@@ -90,197 +175,233 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
",volume_up:0,volume_down:0,volume_mute:0" +
",power:0,screenshot:0" +
- ""
+ "",
)
+
+ // Scrcpy Server
val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"),
- ""
+ "",
)
val CUSTOM_SERVER_VERSION = Pair(
stringPreferencesKey("custom_server_version"),
- ""
+ "",
)
val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"),
Scrcpy.DEFAULT_REMOTE_PATH,
)
+
+ // ADB
val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"),
- "scrcpy"
+ "scrcpy",
)
val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair(
booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"),
- true
+ true,
)
val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair(
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
- true
+ true,
)
val ADB_MDNS_LAN_DISCOVERY = Pair(
// 没必要加开关, 保持启用
booleanPreferencesKey("adb_mdns_lan_discovery"),
- true
+ true,
)
val ADB_AUTO_LOAD_APP_LIST_ON_CONNECT = Pair(
booleanPreferencesKey("adb_auto_load_app_list_on_connect"),
- false
+ false,
)
+
+ // Terminal
+ val TERMINAL_FONT_SIZE_SP = Pair(
+ floatPreferencesKey("terminal_font_size_sp"),
+ 12f,
+ )
+ val TERMINAL_FONT_DISPLAY_NAME = Pair(
+ stringPreferencesKey("terminal_font_display_name"),
+ "",
+ )
+
val PASSWORD_REQUIRE_AUTH = Pair(
booleanPreferencesKey("password_require_auth"),
- true
- )
- val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair(
- booleanPreferencesKey("realtime_clipboard_sync_to_device"),
- true
+ true,
)
+
val FILE_MANAGER_SORT_BY = Pair(
stringPreferencesKey("file_manager_sort_by"),
- "NAME"
+ "NAME",
)
val FILE_MANAGER_SORT_DESCENDING = Pair(
booleanPreferencesKey("file_manager_sort_descending"),
- false
+ false,
)
val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"),
- 0L
+ 0L,
)
}
- // Theme Settings
- val themeBaseIndex by setting(THEME_BASE_INDEX)
- val monet by setting(MONET)
- val blur by setting(BLUR)
- val floatingBottomBar by setting(FLOATING_BOTTOM_BAR)
- val floatingBottomBarBlur by setting(FLOATING_BOTTOM_BAR_BLUR)
- val smoothCorner by setting(SMOOTH_CORNER)
-
- // Scrcpy Settings
- val lowLatency by setting(LOW_LATENCY)
- val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
- val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
- val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
- val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION)
- val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION)
- val hideSimpleConfigItems by setting(HIDE_SIMPLE_CONFIG_ITEMS)
- val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
- val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
- val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
-
- // Scrcpy Server Settings
- val customServerUri by setting(CUSTOM_SERVER_URI)
- val customServerVersion by setting(CUSTOM_SERVER_VERSION)
- val serverRemotePath by setting(SERVER_REMOTE_PATH)
-
- // ADB Settings
- val adbKeyName by setting(ADB_KEY_NAME)
- val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
- val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
- val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
- val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
- val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
- val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE)
- val fileManagerSortBy by setting(FILE_MANAGER_SORT_BY)
- val fileManagerSortDescending by setting(FILE_MANAGER_SORT_DESCENDING)
- val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
-
@Parcelize
data class Bundle(
+ // Theme
val themeBaseIndex: Int,
val monet: Boolean,
val blur: Boolean,
val floatingBottomBar: Boolean,
val floatingBottomBarBlur: Boolean,
val smoothCorner: Boolean,
+
+ // Scrcpy
val lowLatency: Boolean,
val fullscreenDebugInfo: Boolean,
- val showFullscreenVirtualButtons: Boolean,
- val showFullscreenFloatingButton: Boolean,
- val fullscreenFloatingButtonXFraction: Float,
- val fullscreenFloatingButtonYFraction: Float,
val hideSimpleConfigItems: Boolean,
val devicePreviewCardHeightDp: Int,
+ val fullscreenControlIgnoreSystemRotationLock: Boolean,
+ val showFullscreenVirtualButtons: Boolean,
+ val fullscreenVirtualButtonHeightDp: Int,
+ val fullscreenVirtualButtonDock: String,
+ val showFullscreenFloatingButton: Boolean,
+ val fullscreenFloatingButtonSizeDp: Int,
+ val fullscreenFloatingButtonBackgroundAlphaPercent: Int,
+ val fullscreenFloatingButtonRingAlphaPercent: Int,
+ val realtimeClipboardSyncToDevice: Boolean,
+
+ val fullscreenFloatingButtonXFraction: Float,
+ val fullscreenFloatingButtonYFraction: Float,
val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String,
+
+ // Scrcpy Server
val customServerUri: String,
val customServerVersion: String,
val serverRemotePath: String,
+
+ // ADB
val adbKeyName: String,
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean,
val adbAutoLoadAppListOnConnect: Boolean,
+
+ // Terminal
+ val terminalFontSizeSp: Float,
+ val terminalFontDisplayName: String,
+
val passwordRequireAuth: Boolean,
- val realtimeClipboardSyncToDevice: Boolean,
+
val fileManagerSortBy: String,
val fileManagerSortDescending: Boolean,
val lastUpdateCheckAt: Long,
) : Parcelable {
}
- private val bundleFields = arrayOf(
- bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex },
- bundleField(MONET) { bundle: Bundle -> bundle.monet },
- bundleField(BLUR) { bundle: Bundle -> bundle.blur },
- bundleField(FLOATING_BOTTOM_BAR) { bundle: Bundle -> bundle.floatingBottomBar },
- bundleField(FLOATING_BOTTOM_BAR_BLUR) { bundle: Bundle -> bundle.floatingBottomBarBlur },
- bundleField(SMOOTH_CORNER) { bundle: Bundle -> bundle.smoothCorner },
- bundleField(LOW_LATENCY) { bundle: Bundle -> bundle.lowLatency },
- bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
- bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
- bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
- bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction },
- bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction },
- bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { bundle: Bundle -> bundle.hideSimpleConfigItems },
- bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
- bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
- bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
- bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
- bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion },
- bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
- bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
- bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
- bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
- bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
- bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
- bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
- bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice },
- bundleField(FILE_MANAGER_SORT_BY) { bundle: Bundle -> bundle.fileManagerSortBy },
- bundleField(FILE_MANAGER_SORT_DESCENDING) { bundle: Bundle -> bundle.fileManagerSortDescending },
- bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
+ private val bundleFields = arrayOf>(
+ // Theme
+ bundleField(THEME_BASE_INDEX) { it.themeBaseIndex },
+ bundleField(MONET) { it.monet },
+ bundleField(BLUR) { it.blur },
+ bundleField(FLOATING_BOTTOM_BAR) { it.floatingBottomBar },
+ bundleField(FLOATING_BOTTOM_BAR_BLUR) { it.floatingBottomBarBlur },
+ bundleField(SMOOTH_CORNER) { it.smoothCorner },
+
+ // Scrcpy
+ bundleField(LOW_LATENCY) { it.lowLatency },
+ bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
+ bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
+ bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp },
+ bundleField(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK) { it.fullscreenControlIgnoreSystemRotationLock },
+ bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { it.showFullscreenVirtualButtons },
+ bundleField(FULLSCREEN_VIRTUAL_BUTTON_HEIGHT_DP) { it.fullscreenVirtualButtonHeightDp },
+ bundleField(FULLSCREEN_VIRTUAL_BUTTON_DOCK) { it.fullscreenVirtualButtonDock },
+ bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { it.showFullscreenFloatingButton },
+ bundleField(FULLSCREEN_FLOATING_BUTTON_SIZE_DP) { it.fullscreenFloatingButtonSizeDp },
+ bundleField(FULLSCREEN_FLOATING_BUTTON_BACKGROUND_ALPHA_PERCENT) { it.fullscreenFloatingButtonBackgroundAlphaPercent },
+ bundleField(FULLSCREEN_FLOATING_BUTTON_RING_ALPHA_PERCENT) { it.fullscreenFloatingButtonRingAlphaPercent },
+ bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { it.realtimeClipboardSyncToDevice },
+
+ bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { it.fullscreenFloatingButtonXFraction },
+ bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { it.fullscreenFloatingButtonYFraction },
+ bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { it.previewVirtualButtonShowText },
+ bundleField(VIRTUAL_BUTTONS_LAYOUT) { it.virtualButtonsLayout },
+
+ // Scrcpy Server
+ bundleField(CUSTOM_SERVER_URI) { it.customServerUri },
+ bundleField(CUSTOM_SERVER_VERSION) { it.customServerVersion },
+ bundleField(SERVER_REMOTE_PATH) { it.serverRemotePath },
+
+ // ADB
+ bundleField(ADB_KEY_NAME) { it.adbKeyName },
+ bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { it.adbPairingAutoDiscoverOnDialogOpen },
+ bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { it.adbAutoReconnectPairedDevice },
+ bundleField(ADB_MDNS_LAN_DISCOVERY) { it.adbMdnsLanDiscovery },
+ bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { it.adbAutoLoadAppListOnConnect },
+
+ // Terminal
+ bundleField(TERMINAL_FONT_SIZE_SP) { it.terminalFontSizeSp },
+ bundleField(TERMINAL_FONT_DISPLAY_NAME) { it.terminalFontDisplayName },
+
+ bundleField(PASSWORD_REQUIRE_AUTH) { it.passwordRequireAuth },
+
+ bundleField(FILE_MANAGER_SORT_BY) { it.fileManagerSortBy },
+ bundleField(FILE_MANAGER_SORT_DESCENDING) { it.fileManagerSortDescending },
+ bundleField(LAST_UPDATE_CHECK_AT) { it.lastUpdateCheckAt },
)
val bundleState: StateFlow = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
+ // Theme
themeBaseIndex = preferences.read(THEME_BASE_INDEX),
monet = preferences.read(MONET),
blur = preferences.read(BLUR),
floatingBottomBar = preferences.read(FLOATING_BOTTOM_BAR),
floatingBottomBarBlur = preferences.read(FLOATING_BOTTOM_BAR_BLUR),
smoothCorner = preferences.read(SMOOTH_CORNER),
+
+ // Scrcpy
lowLatency = preferences.read(LOW_LATENCY),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
- showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
- showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),
- fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
- fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
+ fullscreenControlIgnoreSystemRotationLock =
+ preferences.read(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK),
+ showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
+ fullscreenVirtualButtonHeightDp = preferences.read(FULLSCREEN_VIRTUAL_BUTTON_HEIGHT_DP),
+ fullscreenVirtualButtonDock = preferences.read(FULLSCREEN_VIRTUAL_BUTTON_DOCK),
+ showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),
+ fullscreenFloatingButtonSizeDp = preferences.read(FULLSCREEN_FLOATING_BUTTON_SIZE_DP),
+ fullscreenFloatingButtonBackgroundAlphaPercent =
+ preferences.read(FULLSCREEN_FLOATING_BUTTON_BACKGROUND_ALPHA_PERCENT),
+ fullscreenFloatingButtonRingAlphaPercent =
+ preferences.read(FULLSCREEN_FLOATING_BUTTON_RING_ALPHA_PERCENT),
+ realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
+
+ fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
+ fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
+
+ // Scrcpy Server
customServerUri = preferences.read(CUSTOM_SERVER_URI),
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
+
+ // ADB
adbKeyName = preferences.read(ADB_KEY_NAME),
- adbPairingAutoDiscoverOnDialogOpen = preferences.read(
- ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN
- ),
+ adbPairingAutoDiscoverOnDialogOpen =
+ preferences.read(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN),
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
+
+ // Terminal
+ terminalFontSizeSp = preferences.read(TERMINAL_FONT_SIZE_SP),
+ terminalFontDisplayName = preferences.read(TERMINAL_FONT_DISPLAY_NAME),
+
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
- realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
fileManagerSortBy = preferences.read(FILE_MANAGER_SORT_BY),
fileManagerSortDescending = preferences.read(FILE_MANAGER_SORT_DESCENDING),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt
deleted file mode 100644
index 47455f7..0000000
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt
+++ /dev/null
@@ -1,564 +0,0 @@
-package io.github.miuzarte.scrcpyforandroid.storage
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.util.Log
-import androidx.core.content.edit
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-private const val TAG = "PreferenceMigration"
-
-/**
- * 从旧的 SharedPreferences 迁移到新的 DataStore
- */
-class PreferenceMigration(private val appContext: Context) {
- private val appSharedPrefs: SharedPreferences by lazy {
- appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- }
-
- private val sharedPrefs: SharedPreferences by lazy {
- appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE)
- }
-
- /**
- * 检查是否需要迁移(SharedPreferences 是否包含数据)
- */
- suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) {
- appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty()
- }
-
- /**
- * 执行完整迁移
- */
- suspend fun migrate(
- clearSharedPrefs: Boolean = false,
- ) = withContext(Dispatchers.IO) {
- if (!needsMigration()) {
- Log.i(TAG, "No data to migrate, skipping")
- return@withContext
- } else {
- val appList = appSharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
- val adbList = sharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
- Log.d(TAG, "Migrating appSharedPrefs ($appList)")
- }
-
- Log.i(TAG, "Starting migration from SharedPreferences to DataStore")
-
- // 迁移 AppSettings
- migrateAppSettings()
-
- // 迁移 ScrcpyOptions
- migrateScrcpyOptions()
-
- // 迁移 QuickDevices
- migrateQuickDevices()
-
- // 迁移 ADB 密钥
- migrateAdbClientData()
-
- // 清空 SharedPreferences
- if (clearSharedPrefs) {
- appSharedPrefs.edit { clear() }
- sharedPrefs.edit { clear() }
- Log.d(TAG, "SharedPreferences cleared")
- }
-
- Log.i(
- TAG, "Migration completed successfully" +
- " and SharedPreferences ${if (clearSharedPrefs) "" else "is not "}cleared"
- )
- }
-
- /**
- * 迁移应用设置
- */
- private suspend fun migrateAppSettings() {
- val appSettings = Storage.appSettings
-
- // Theme Settings
- migrateInt(
- THEME_BASE_INDEX,
- AppSettings.THEME_BASE_INDEX.defaultValue,
- appSettings.themeBaseIndex,
- )
- migrateBoolean(
- MONET,
- AppSettings.MONET.defaultValue,
- appSettings.monet,
- )
-
- // Scrcpy Settings
- migrateBoolean(
- FULLSCREEN_DEBUG_INFO,
- AppSettings.FULLSCREEN_DEBUG_INFO.defaultValue,
- appSettings.fullscreenDebugInfo,
- )
- migrateBoolean(
- SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
- AppSettings.SHOW_FULLSCREEN_VIRTUAL_BUTTONS.defaultValue,
- appSettings.showFullscreenVirtualButtons,
- )
- migrateInt(
- DEVICE_PREVIEW_CARD_HEIGHT_DP,
- AppSettings.DEVICE_PREVIEW_CARD_HEIGHT_DP.defaultValue,
- appSettings.devicePreviewCardHeightDp,
- )
- migrateBoolean(
- PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
- AppSettings.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.defaultValue,
- appSettings.previewVirtualButtonShowText,
- )
- migrateString(
- VIRTUAL_BUTTONS_LAYOUT,
- AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue,
- appSettings.virtualButtonsLayout,
- )
-
- // Scrcpy Server Settings
- migrateString(
- CUSTOM_SERVER_URI,
- AppSettings.CUSTOM_SERVER_URI.defaultValue,
- appSettings.customServerUri,
- )
- migrateString(
- SERVER_REMOTE_PATH,
- AppSettings.SERVER_REMOTE_PATH.defaultValue,
- appSettings.serverRemotePath,
- )
-
- // ADB Settings
- migrateString(
- ADB_KEY_NAME,
- AppSettings.ADB_KEY_NAME.defaultValue,
- appSettings.adbKeyName,
- )
- migrateBoolean(
- ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
- AppSettings.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.defaultValue,
- appSettings.adbPairingAutoDiscoverOnDialogOpen,
- )
- migrateBoolean(
- ADB_AUTO_RECONNECT_PAIRED_DEVICE,
- AppSettings.ADB_AUTO_RECONNECT_PAIRED_DEVICE.defaultValue,
- appSettings.adbAutoReconnectPairedDevice,
- )
- migrateBoolean(
- ADB_MDNS_LAN_DISCOVERY,
- AppSettings.ADB_MDNS_LAN_DISCOVERY.defaultValue,
- appSettings.adbMdnsLanDiscovery,
- )
-
- Log.d(TAG, "AppSettings migration completed")
- }
-
- /**
- * 迁移 Scrcpy 选项
- */
- private suspend fun migrateScrcpyOptions() {
- val scrcpyOptions = Storage.scrcpyOptions
-
- // Audio & Video Codecs
- migrateString(
- AUDIO_CODEC,
- ScrcpyOptions.AUDIO_CODEC.defaultValue,
- scrcpyOptions.audioCodec,
- )
- migrateString(
- VIDEO_CODEC,
- ScrcpyOptions.VIDEO_CODEC.defaultValue,
- scrcpyOptions.videoCodec,
- )
-
- // Bit Rates
- val audioBitRateKbps = sharedPrefs.getInt(
- AUDIO_BIT_RATE_KBPS,
- ScrcpyOptions.AUDIO_BIT_RATE.defaultValue / 1_000,
- )
- scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1_000) // Convert to bps
-
- val videoBitRateMbps = sharedPrefs.getFloat(
- VIDEO_BIT_RATE_MBPS,
- (ScrcpyOptions.VIDEO_BIT_RATE.defaultValue / 1_000_000).toFloat(),
- )
- scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt())
-
- // Control Options
- migrateBoolean(
- TURN_SCREEN_OFF,
- ScrcpyOptions.TURN_SCREEN_OFF.defaultValue,
- scrcpyOptions.turnScreenOff,
- )
- migrateBoolean(
- NO_CONTROL,
- !ScrcpyOptions.CONTROL.defaultValue,
- ) { value ->
- scrcpyOptions.control.set(!value) // Invert logic
- }
- migrateBoolean(
- NO_VIDEO,
- !ScrcpyOptions.VIDEO.defaultValue,
- ) { value ->
- scrcpyOptions.video.set(!value) // Invert logic
- }
-
- // Video Source
- val videoSourcePreset = sharedPrefs.getString(
- VIDEO_SOURCE_PRESET,
- ScrcpyOptions.VIDEO_SOURCE.defaultValue,
- ).orEmpty().ifBlank { ScrcpyOptions.VIDEO_SOURCE.defaultValue }
- scrcpyOptions.videoSource.set(videoSourcePreset)
-
- migrateString(
- DISPLAY_ID,
- ScrcpyOptions.DISPLAY_ID.defaultValue.toString(),
- ) { value ->
- value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) }
- }
-
- // Camera Settings
- migrateString(
- CAMERA_ID,
- ScrcpyOptions.CAMERA_ID.defaultValue,
- scrcpyOptions.cameraId,
- )
- migrateString(
- CAMERA_FACING_PRESET,
- ScrcpyOptions.CAMERA_FACING.defaultValue,
- scrcpyOptions.cameraFacing,
- )
- migrateString(
- CAMERA_SIZE_PRESET,
- ScrcpyOptions.CAMERA_SIZE.defaultValue,
- ) { value ->
- if (value == "custom") {
- val customSize = sharedPrefs.getString(
- CAMERA_SIZE_CUSTOM,
- ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue,
- ).orEmpty()
- scrcpyOptions.cameraSizeCustom.set(customSize)
- scrcpyOptions.cameraSizeUseCustom.set(true)
- } else {
- scrcpyOptions.cameraSize.set(value)
- }
- }
- migrateString(
- CAMERA_AR,
- ScrcpyOptions.CAMERA_AR.defaultValue,
- scrcpyOptions.cameraAr,
- )
- migrateString(
- CAMERA_FPS,
- ScrcpyOptions.CAMERA_FPS.defaultValue.toString(),
- ) { value ->
- value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) }
- }
- migrateBoolean(
- CAMERA_HIGH_SPEED,
- ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue,
- scrcpyOptions.cameraHighSpeed,
- )
-
- // Audio Source
- val audioSourcePreset = sharedPrefs.getString(
- AUDIO_SOURCE_PRESET,
- "auto",
- ).orEmpty().ifBlank { "auto" }
-
- if (audioSourcePreset == "custom") {
- val customSource = sharedPrefs.getString(
- AUDIO_SOURCE_CUSTOM,
- ScrcpyOptions.AUDIO_SOURCE.defaultValue,
- ).orEmpty()
- scrcpyOptions.audioSource.set(customSource)
- } else {
- scrcpyOptions.audioSource.set(audioSourcePreset)
- }
-
- migrateBoolean(
- AUDIO_DUP,
- ScrcpyOptions.AUDIO_DUP.defaultValue,
- scrcpyOptions.audioDup,
- )
- migrateBoolean(
- NO_AUDIO_PLAYBACK,
- !ScrcpyOptions.AUDIO_PLAYBACK.defaultValue,
- ) { value ->
- scrcpyOptions.audioPlayback.set(!value) // Invert logic
- }
- migrateBoolean(
- REQUIRE_AUDIO,
- ScrcpyOptions.REQUIRE_AUDIO.defaultValue,
- scrcpyOptions.requireAudio,
- )
-
- // Max Size & FPS
- migrateString(
- MAX_SIZE_INPUT,
- ScrcpyOptions.MAX_SIZE.defaultValue.toString(),
- ) { value ->
- value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) }
- }
- migrateString(
- MAX_FPS_INPUT,
- ScrcpyOptions.MAX_FPS.defaultValue,
- scrcpyOptions.maxFps,
- )
-
- // Encoders & Codec Options
- migrateString(
- VIDEO_ENCODER,
- ScrcpyOptions.VIDEO_ENCODER.defaultValue,
- scrcpyOptions.videoEncoder,
- )
- migrateString(
- VIDEO_CODEC_OPTION,
- ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue,
- scrcpyOptions.videoCodecOptions,
- )
- migrateString(
- AUDIO_ENCODER,
- ScrcpyOptions.AUDIO_ENCODER.defaultValue,
- scrcpyOptions.audioEncoder,
- )
- migrateString(
- AUDIO_CODEC_OPTION,
- ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue,
- scrcpyOptions.audioCodecOptions,
- )
-
- // New Display
- val newDisplayWidth = sharedPrefs.getString(
- NEW_DISPLAY_WIDTH,
- "",
- ).orEmpty()
- val newDisplayHeight = sharedPrefs.getString(
- NEW_DISPLAY_HEIGHT,
- "",
- ).orEmpty()
- val newDisplayDpi = sharedPrefs.getString(
- NEW_DISPLAY_DPI,
- "",
- ).orEmpty()
-
- if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) {
- val newDisplay = if (newDisplayDpi.isNotBlank()) {
- "${newDisplayWidth}x${newDisplayHeight}/${newDisplayDpi}"
- } else {
- "${newDisplayWidth}x${newDisplayHeight}"
- }
- scrcpyOptions.newDisplay.set(newDisplay)
- }
-
- // Crop
- val cropWidth = sharedPrefs.getString(
- CROP_WIDTH,
- "",
- ).orEmpty()
- val cropHeight = sharedPrefs.getString(
- CROP_HEIGHT,
- "",
- ).orEmpty()
- val cropX = sharedPrefs.getString(
- CROP_X,
- "",
- ).orEmpty()
- val cropY = sharedPrefs.getString(
- CROP_Y,
- "",
- ).orEmpty()
-
- if (cropWidth.isNotBlank() && cropHeight.isNotBlank()
- && cropX.isNotBlank() && cropY.isNotBlank()
- ) {
- scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}")
- }
-
- migrateBoolean(
- AUDIO_ENABLED,
- ScrcpyOptions.AUDIO.defaultValue,
- scrcpyOptions.audio,
- )
-
- Log.d(TAG, "ScrcpyOptions migration completed")
- }
-
- /**
- * 迁移快速设备列表
- */
- private suspend fun migrateQuickDevices() {
- val quickDevices = Storage.quickDevices
-
- // Migrate quick devices list
- val quickDevicesRaw = appSharedPrefs.getString(
- QUICK_DEVICES,
- ""
- ).orEmpty()
- if (quickDevicesRaw.isNotBlank()) {
- quickDevices.quickDevicesList.set(quickDevicesRaw)
- }
-
- // Migrate quick connect input
- migrateString(
- QUICK_CONNECT_INPUT,
- QuickDevices.QUICK_CONNECT_INPUT.defaultValue,
- quickDevices.quickConnectInput,
- )
-
- Log.d(TAG, "QuickDevices migration completed")
- }
-
- /**
- * 迁移 ADB 客户端数据(RSA 密钥)
- */
- private suspend fun migrateAdbClientData() {
- val adbClientData = Storage.adbClientData
-
- // 迁移 RSA 私钥
- val privKey = sharedPrefs.getString("priv", null)
- if (privKey != null) {
- adbClientData.rsaPrivateKey.set(privKey)
- Log.d(TAG, "ADB RSA private key migrated")
- }
-
- Log.d(TAG, "AdbClientData migration completed")
- }
-
- // Helper methods for different data types
-
- private suspend fun migrateString(
- key: String,
- defaultValue: String,
- settingProperty: Settings.SettingProperty
- ) {
- val value = appSharedPrefs.getString(key, defaultValue)
- .orEmpty()
- .ifBlank { defaultValue }
- settingProperty.set(value)
- }
-
- private suspend fun migrateString(
- key: String,
- defaultValue: String,
- action: suspend (String) -> Unit
- ) {
- val value = appSharedPrefs.getString(key, defaultValue)
- .orEmpty()
- .ifBlank { defaultValue }
- action(value)
- }
-
- private suspend fun migrateInt(
- key: String,
- defaultValue: Int,
- settingProperty: Settings.SettingProperty
- ) {
- val value = appSharedPrefs.getInt(key, defaultValue)
- settingProperty.set(value)
- }
-
- private suspend fun migrateInt(
- key: String,
- defaultValue: Int,
- action: suspend (Int) -> Unit
- ) {
- val value = appSharedPrefs.getInt(key, defaultValue)
- action(value)
- }
-
- private suspend fun migrateBoolean(
- key: String,
- defaultValue: Boolean,
- settingProperty: Settings.SettingProperty
- ) {
- val value = appSharedPrefs.getBoolean(key, defaultValue)
- settingProperty.set(value)
- }
-
- private suspend fun migrateBoolean(
- key: String,
- defaultValue: Boolean,
- action: suspend (Boolean) -> Unit
- ) {
- val value = appSharedPrefs.getBoolean(key, defaultValue)
- action(value)
- }
-
- companion object {
- const val PREFS_NAME = "scrcpy_app_prefs"
-
- // Devices
- const val QUICK_DEVICES = "quick_devices"
- const val QUICK_CONNECT_INPUT = "quick_connect_input"
-
- const val PAIR_HOST = "pair_host"
- const val PAIR_PORT = "pair_port"
- const val PAIR_CODE = "pair_code"
-
- const val AUDIO_ENABLED = "audio_enabled"
- const val AUDIO_CODEC = "audio_codec"
- const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input"
- const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps"
- const val VIDEO_CODEC = "video_codec"
- const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps"
- const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input"
-
- const val TURN_SCREEN_OFF = "turn_screen_off"
- const val NO_CONTROL = "no_control"
- const val NO_VIDEO = "no_video"
-
- const val VIDEO_SOURCE_PRESET = "video_source_preset"
- const val DISPLAY_ID = "display_id"
-
- const val CAMERA_ID = "camera_id"
- const val CAMERA_FACING_PRESET = "camera_facing_preset"
- const val CAMERA_SIZE_PRESET = "camera_size_preset"
- const val CAMERA_SIZE_CUSTOM = "camera_size_custom"
- const val CAMERA_AR = "camera_ar"
- const val CAMERA_FPS = "camera_fps"
- const val CAMERA_HIGH_SPEED = "camera_high_speed"
-
- const val AUDIO_SOURCE_PRESET = "audio_source_preset"
- const val AUDIO_SOURCE_CUSTOM = "audio_source_custom"
- const val AUDIO_DUP = "audio_dup"
- const val NO_AUDIO_PLAYBACK = "no_audio_playback"
- const val REQUIRE_AUDIO = "require_audio"
-
- const val MAX_SIZE_INPUT = "max_size_input"
- const val MAX_FPS_INPUT = "max_fps_input"
-
- const val VIDEO_ENCODER = "video_encoder"
- const val VIDEO_CODEC_OPTION = "video_codec_options"
- const val AUDIO_ENCODER = "audio_encoder"
- const val AUDIO_CODEC_OPTION = "audio_codec_options"
-
- const val NEW_DISPLAY_WIDTH = "new_display_width"
- const val NEW_DISPLAY_HEIGHT = "new_display_height"
- const val NEW_DISPLAY_DPI = "new_display_dpi"
-
- const val CROP_WIDTH = "crop_width"
- const val CROP_HEIGHT = "crop_height"
- const val CROP_X = "crop_x"
- const val CROP_Y = "crop_y"
-
- // Settings
- const val THEME_BASE_INDEX = "theme_base_index"
- const val MONET = "monet"
-
- const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info"
- const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
- const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
- const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
- const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
-
- const val CUSTOM_SERVER_URI = "custom_server_uri"
-
- const val SERVER_REMOTE_PATH = "server_remote_path"
-
- const val ADB_KEY_NAME = "adb_key_name"
- const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN =
- "adb_pairing_auto_discover_on_dialog_open"
- const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
- const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
- }
-}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt
index bc0af4b..ccd05a2 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt
@@ -29,9 +29,9 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
) : Parcelable {
}
- private val bundleFields = arrayOf(
- bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList },
- bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput },
+ private val bundleFields = arrayOf>(
+ bundleField(QUICK_DEVICES_LIST) { it.quickDevicesList },
+ bundleField(QUICK_CONNECT_INPUT) { it.quickConnectInput },
)
val bundleState: StateFlow = createBundleState(::bundleFromPreferences)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt
index 48d31af..38bb9f9 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt
@@ -200,6 +200,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("downsize_on_error"),
true,
)
+ val MOUSE_HOVER = Pair(
+ booleanPreferencesKey("mouse_hover"),
+ true,
+ )
val CLEANUP = Pair(
booleanPreferencesKey("cleanup"),
true,
@@ -305,6 +309,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
legacyPaste = LEGACY_PASTE.defaultValue,
clipboardAutosync = CLIPBOARD_AUTOSYNC.defaultValue,
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
+ mouseHover = MOUSE_HOVER.defaultValue,
cleanup = CLEANUP.defaultValue,
powerOn = POWER_ON.defaultValue,
video = VIDEO.defaultValue,
@@ -323,62 +328,6 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
)
}
- val crop by setting(CROP)
- val recordFilename by setting(RECORD_FILENAME)
- val videoCodecOptions by setting(VIDEO_CODEC_OPTIONS)
- val audioCodecOptions by setting(AUDIO_CODEC_OPTIONS)
- val videoEncoder by setting(VIDEO_ENCODER)
- val audioEncoder by setting(AUDIO_ENCODER)
- val cameraId by setting(CAMERA_ID)
- val cameraSize by setting(CAMERA_SIZE)
- val cameraSizeCustom by setting(CAMERA_SIZE_CUSTOM)
- val cameraSizeUseCustom by setting(CAMERA_SIZE_USE_CUSTOM)
- val cameraAr by setting(CAMERA_AR)
- val cameraFps by setting(CAMERA_FPS)
- val logLevel by setting(LOG_LEVEL)
- val videoCodec by setting(VIDEO_CODEC)
- val audioCodec by setting(AUDIO_CODEC)
- val videoSource by setting(VIDEO_SOURCE)
- val audioSource by setting(AUDIO_SOURCE)
- val recordFormat by setting(RECORD_FORMAT)
- val cameraFacing by setting(CAMERA_FACING)
- val maxSize by setting(MAX_SIZE)
- val videoBitRate by setting(VIDEO_BIT_RATE)
- val audioBitRate by setting(AUDIO_BIT_RATE)
- val maxFps by setting(MAX_FPS)
- val angle by setting(ANGLE)
- val captureOrientation by setting(CAPTURE_ORIENTATION)
- val captureOrientationLock by setting(CAPTURE_ORIENTATION_LOCK)
- val displayOrientation by setting(DISPLAY_ORIENTATION)
- val recordOrientation by setting(RECORD_ORIENTATION)
- val displayImePolicy by setting(DISPLAY_IME_POLICY)
- val displayId by setting(DISPLAY_ID)
- val screenOffTimeout by setting(SCREEN_OFF_TIMEOUT)
- val showTouches by setting(SHOW_TOUCHES)
- val fullscreen by setting(FULLSCREEN)
- val control by setting(CONTROL)
- val videoPlayback by setting(VIDEO_PLAYBACK)
- val audioPlayback by setting(AUDIO_PLAYBACK)
- val turnScreenOff by setting(TURN_SCREEN_OFF)
- val stayAwake by setting(STAY_AWAKE)
- val disableScreensaver by setting(DISABLE_SCREENSAVER)
- val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
- val legacyPaste by setting(LEGACY_PASTE)
- val clipboardAutosync by setting(CLIPBOARD_AUTOSYNC)
- val cleanup by setting(CLEANUP)
- val powerOn by setting(POWER_ON)
- val video by setting(VIDEO)
- val audio by setting(AUDIO)
- val requireAudio by setting(REQUIRE_AUDIO)
- val killAdbOnClose by setting(KILL_ADB_ON_CLOSE)
- val cameraHighSpeed by setting(CAMERA_HIGH_SPEED)
- val list by setting(LIST)
- val audioDup by setting(AUDIO_DUP)
- val newDisplay by setting(NEW_DISPLAY)
- val startApp by setting(START_APP)
- val vdDestroyContent by setting(VD_DESTROY_CONTENT)
- val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
-
@Parcelize
data class Bundle(
val crop: String,
@@ -424,6 +373,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val legacyPaste: Boolean,
val clipboardAutosync: Boolean,
val downsizeOnError: Boolean,
+ val mouseHover: Boolean,
val cleanup: Boolean,
val powerOn: Boolean,
val video: Boolean,
@@ -442,64 +392,65 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
) : Parcelable {
}
- private val bundleFields = arrayOf(
- bundleField(CROP) { bundle: Bundle -> bundle.crop },
- bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename },
- bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions },
- bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions },
- bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder },
- bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder },
- bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId },
- bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize },
- bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom },
- bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom },
- bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr },
- bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps },
- bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel },
- bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec },
- bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec },
- bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource },
- bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource },
- bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat },
- bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing },
- bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize },
- bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate },
- bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate },
- bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps },
- bundleField(ANGLE) { bundle: Bundle -> bundle.angle },
- bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation },
- bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock },
- bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation },
- bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation },
- bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy },
- bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId },
- bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout },
- bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches },
- bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen },
- bundleField(CONTROL) { bundle: Bundle -> bundle.control },
- bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback },
- bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback },
- bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff },
- bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
- bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
- bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
- bundleField(LEGACY_PASTE) { bundle: Bundle -> bundle.legacyPaste },
- bundleField(CLIPBOARD_AUTOSYNC) { bundle: Bundle -> bundle.clipboardAutosync },
- bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
- bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
- bundleField(VIDEO) { bundle: Bundle -> bundle.video },
- bundleField(AUDIO) { bundle: Bundle -> bundle.audio },
- bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio },
- bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose },
- bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed },
- bundleField(LIST) { bundle: Bundle -> bundle.list },
- bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup },
- bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay },
- bundleField(START_APP) { bundle: Bundle -> bundle.startApp },
- bundleField(START_APP_CUSTOM) { bundle: Bundle -> bundle.startAppCustom },
- bundleField(START_APP_USE_CUSTOM) { bundle: Bundle -> bundle.startAppUseCustom },
- bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent },
- bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations },
+ private val bundleFields = arrayOf>(
+ bundleField(CROP) { it.crop },
+ bundleField(RECORD_FILENAME) { it.recordFilename },
+ bundleField(VIDEO_CODEC_OPTIONS) { it.videoCodecOptions },
+ bundleField(AUDIO_CODEC_OPTIONS) { it.audioCodecOptions },
+ bundleField(VIDEO_ENCODER) { it.videoEncoder },
+ bundleField(AUDIO_ENCODER) { it.audioEncoder },
+ bundleField(CAMERA_ID) { it.cameraId },
+ bundleField(CAMERA_SIZE) { it.cameraSize },
+ bundleField(CAMERA_SIZE_CUSTOM) { it.cameraSizeCustom },
+ bundleField(CAMERA_SIZE_USE_CUSTOM) { it.cameraSizeUseCustom },
+ bundleField(CAMERA_AR) { it.cameraAr },
+ bundleField(CAMERA_FPS) { it.cameraFps },
+ bundleField(LOG_LEVEL) { it.logLevel },
+ bundleField(VIDEO_CODEC) { it.videoCodec },
+ bundleField(AUDIO_CODEC) { it.audioCodec },
+ bundleField(VIDEO_SOURCE) { it.videoSource },
+ bundleField(AUDIO_SOURCE) { it.audioSource },
+ bundleField(RECORD_FORMAT) { it.recordFormat },
+ bundleField(CAMERA_FACING) { it.cameraFacing },
+ bundleField(MAX_SIZE) { it.maxSize },
+ bundleField(VIDEO_BIT_RATE) { it.videoBitRate },
+ bundleField(AUDIO_BIT_RATE) { it.audioBitRate },
+ bundleField(MAX_FPS) { it.maxFps },
+ bundleField(ANGLE) { it.angle },
+ bundleField(CAPTURE_ORIENTATION) { it.captureOrientation },
+ bundleField(CAPTURE_ORIENTATION_LOCK) { it.captureOrientationLock },
+ bundleField(DISPLAY_ORIENTATION) { it.displayOrientation },
+ bundleField(RECORD_ORIENTATION) { it.recordOrientation },
+ bundleField(DISPLAY_IME_POLICY) { it.displayImePolicy },
+ bundleField(DISPLAY_ID) { it.displayId },
+ bundleField(SCREEN_OFF_TIMEOUT) { it.screenOffTimeout },
+ bundleField(SHOW_TOUCHES) { it.showTouches },
+ bundleField(FULLSCREEN) { it.fullscreen },
+ bundleField(CONTROL) { it.control },
+ bundleField(VIDEO_PLAYBACK) { it.videoPlayback },
+ bundleField(AUDIO_PLAYBACK) { it.audioPlayback },
+ bundleField(TURN_SCREEN_OFF) { it.turnScreenOff },
+ bundleField(STAY_AWAKE) { it.stayAwake },
+ bundleField(DISABLE_SCREENSAVER) { it.disableScreensaver },
+ bundleField(POWER_OFF_ON_CLOSE) { it.powerOffOnClose },
+ bundleField(LEGACY_PASTE) { it.legacyPaste },
+ bundleField(CLIPBOARD_AUTOSYNC) { it.clipboardAutosync },
+ bundleField(MOUSE_HOVER) { it.mouseHover },
+ bundleField(CLEANUP) { it.cleanup },
+ bundleField(POWER_ON) { it.powerOn },
+ bundleField(VIDEO) { it.video },
+ bundleField(AUDIO) { it.audio },
+ bundleField(REQUIRE_AUDIO) { it.requireAudio },
+ bundleField(KILL_ADB_ON_CLOSE) { it.killAdbOnClose },
+ bundleField(CAMERA_HIGH_SPEED) { it.cameraHighSpeed },
+ bundleField(LIST) { it.list },
+ bundleField(AUDIO_DUP) { it.audioDup },
+ bundleField(NEW_DISPLAY) { it.newDisplay },
+ bundleField(START_APP) { it.startApp },
+ bundleField(START_APP_CUSTOM) { it.startAppCustom },
+ bundleField(START_APP_USE_CUSTOM) { it.startAppUseCustom },
+ bundleField(VD_DESTROY_CONTENT) { it.vdDestroyContent },
+ bundleField(VD_SYSTEM_DECORATIONS) { it.vdSystemDecorations },
)
val bundleState: StateFlow = createBundleState(::bundleFromPreferences)
@@ -548,6 +499,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
legacyPaste = preferences.read(LEGACY_PASTE),
clipboardAutosync = preferences.read(CLIPBOARD_AUTOSYNC),
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
+ mouseHover = preferences.read(MOUSE_HOVER),
cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON),
video = preferences.read(VIDEO),
@@ -625,6 +577,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
legacyPaste = bundle.legacyPaste,
clipboardAutosync = bundle.clipboardAutosync,
downsizeOnError = bundle.downsizeOnError,
+ mouseHover = bundle.mouseHover,
cleanup = bundle.cleanup,
powerOn = bundle.powerOn,
video = bundle.video,
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
index 1962b49..c6d62b5 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
@@ -1,11 +1,31 @@
package io.github.miuzarte.scrcpyforandroid.widgets
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import top.yukonga.miuix.kmp.basic.DropdownColors
+import top.yukonga.miuix.kmp.basic.DropdownDefaults
+import top.yukonga.miuix.kmp.basic.BasicComponent
+import top.yukonga.miuix.kmp.basic.BasicComponentColors
+import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
+import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.HorizontalDivider
+import top.yukonga.miuix.kmp.basic.ListPopupColumn
+import top.yukonga.miuix.kmp.basic.PopupPositionProvider
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.overlay.OverlayListPopup
+import top.yukonga.miuix.kmp.theme.MiuixTheme
data class MultiGroupsDropdownGroup(
val options: List,
@@ -13,10 +33,91 @@ data class MultiGroupsDropdownGroup(
val onSelectedIndexChange: (Int) -> Unit,
)
+@Composable
+fun MultiGroupsDropdownPreference(
+ title: String,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ groups: List,
+ titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
+ summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
+ insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
+ dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
+ enabled: Boolean = true,
+ showValue: Boolean = true,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isExpanded = remember { mutableStateOf(false) }
+ val isHoldDown = remember { mutableStateOf(false) }
+ val hapticFeedback = LocalHapticFeedback.current
+ val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
+
+ val hasOptions = groups.any { it.options.isNotEmpty() }
+ val actualEnabled = enabled && hasOptions
+ val actionColor = if (actualEnabled) {
+ MiuixTheme.colorScheme.onSurfaceVariantActions
+ } else {
+ MiuixTheme.colorScheme.disabledOnSecondaryVariant
+ }
+ val selectedValueText = groups.joinToString("\n") { group ->
+ group.options.getOrNull(group.selectedIndex).orEmpty()
+ }.ifBlank { null }
+
+ BasicComponent(
+ modifier = modifier,
+ interactionSource = interactionSource,
+ insideMargin = insideMargin,
+ title = title,
+ titleColor = titleColor,
+ summary = summary,
+ summaryColor = summaryColor,
+ endActions = {
+ if (showValue && !selectedValueText.isNullOrBlank()) {
+ Text(
+ text = selectedValueText,
+ modifier = Modifier.padding(end = 8.dp),
+ fontSize = MiuixTheme.textStyles.body2.fontSize,
+ color = actionColor,
+ textAlign = TextAlign.End,
+ lineHeight = MiuixTheme.textStyles.body2.lineHeight,
+ )
+ }
+ DropdownArrowEndAction(actionColor = actionColor)
+ if (hasOptions) {
+ OverlayListPopup(
+ show = isExpanded.value,
+ alignment = PopupPositionProvider.Align.End,
+ onDismissRequest = { isExpanded.value = false },
+ onDismissFinished = { isHoldDown.value = false },
+ ) {
+ ListPopupColumn {
+ MultiGroupsDropdown(
+ groups = groups,
+ dropdownColors = dropdownColors,
+ )
+ }
+ }
+ }
+ },
+ onClick = {
+ if (actualEnabled) {
+ isExpanded.value = !isExpanded.value
+ if (isExpanded.value) {
+ isHoldDown.value = true
+ currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ }
+ },
+ holdDownState = isHoldDown.value,
+ enabled = actualEnabled,
+ )
+}
+
@Composable
fun MultiGroupsDropdown(
groups: List,
modifier: Modifier = Modifier,
+ dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
) {
groups.forEachIndexed { groupIndex, group ->
group.options.forEachIndexed { optionIndex, option ->
@@ -24,6 +125,7 @@ fun MultiGroupsDropdown(
text = option,
optionSize = group.options.size,
isSelected = optionIndex == group.selectedIndex,
+ dropdownColors = dropdownColors,
index = optionIndex,
onSelectedIndexChange = group.onSelectedIndexChange,
)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt
index e2c66b1..fac4389 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt
@@ -5,11 +5,14 @@ import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -47,6 +50,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
@@ -229,6 +233,13 @@ class VirtualButtonBar(
private val outsideActions: List,
private val moreActions: List,
) {
+ enum class FullscreenDock {
+ TOP,
+ BOTTOM,
+ LEFT,
+ RIGHT,
+ }
+
private enum class ActionPopupDestination {
Actions,
Passwords,
@@ -331,6 +342,9 @@ class VirtualButtonBar(
fun Fullscreen(
onAction: suspend (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
+ dock: FullscreenDock = FullscreenDock.BOTTOM,
+ reverseOrder: Boolean = false,
+ thickness: Dp = 16.dp,
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
@@ -338,11 +352,30 @@ class VirtualButtonBar(
var showMorePopup by remember { mutableStateOf(false) }
var showPasswordPopup by remember { mutableStateOf(false) }
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(0.dp),
+ val isVertical = dock == FullscreenDock.LEFT || dock == FullscreenDock.RIGHT
+ val visibleActions =
+ if (reverseOrder) outsideActions.asReversed()
+ else outsideActions
+ val containerModifier =
+ if (isVertical) modifier
+ .width(thickness)
+ .fillMaxHeight()
+ else modifier
+ .fillMaxWidth()
+ .height(thickness)
+
+ val buttonModifier =
+ if (isVertical) Modifier
+ .fillMaxSize()
+ else Modifier
+ .fillMaxWidth()
+ .height(thickness)
+
+ if (isVertical) Column(
+ modifier = containerModifier,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
) {
- outsideActions.forEach { action ->
+ visibleActions.forEach { action ->
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = {
@@ -360,9 +393,62 @@ class VirtualButtonBar(
else -> scope.launch { onAction(action) }
}
},
- modifier = Modifier.fillMaxWidth(),
+ modifier = buttonModifier,
cornerRadius = 0.dp,
- minHeight = 16.dp,
+ minHeight = thickness,
+ insideMargin = PaddingValues(0.dp),
+ colors = ButtonDefaults.buttonColors(
+ color = Color.Black.copy(alpha = 0.1f),
+ ),
+ ) {
+ Icon(action.icon, contentDescription = action.title, tint = Color.White)
+ }
+
+ if (action == VirtualButtonAction.MORE) {
+ ActionPopup(
+ show = showMorePopup,
+ actions = moreActions,
+ onDismiss = { showMorePopup = false },
+ onAction = {
+ if (it == VirtualButtonAction.PASSWORD_INPUT
+ && passwordPopupContent != null
+ ) showPasswordPopup = true
+ else onAction(it)
+
+ showMorePopup = false
+ },
+ passwordPopupContent = passwordPopupContent,
+ renderInRootScaffold = true,
+ )
+ }
+ }
+ }
+ }
+ else Row(
+ modifier = containerModifier,
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ visibleActions.forEach { action ->
+ Box(modifier = Modifier.weight(1f)) {
+ Button(
+ onClick = {
+ haptics.contextClick()
+ when (action) {
+ VirtualButtonAction.MORE -> {
+ showMorePopup = true
+ }
+
+ VirtualButtonAction.PASSWORD_INPUT
+ if passwordPopupContent != null -> {
+ showPasswordPopup = true
+ }
+
+ else -> scope.launch { onAction(action) }
+ }
+ },
+ modifier = buttonModifier,
+ cornerRadius = 0.dp,
+ minHeight = thickness,
insideMargin = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(
color = Color.Black.copy(alpha = 0.1f),
@@ -448,9 +534,15 @@ class VirtualButtonBar(
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
- val ballSize = 48.dp
- val ringSize = 24.dp
- val ringWidth = 2.dp
+ val ballSize = asBundleShared.fullscreenFloatingButtonSizeDp.dp
+ val ringSize = ballSize / 2
+ val ringWidth = ballSize / 24
+ val backgroundAlpha =
+ (asBundleShared.fullscreenFloatingButtonBackgroundAlphaPercent / 100f)
+ .coerceIn(0.1f, 1f)
+ val ringAlpha =
+ (asBundleShared.fullscreenFloatingButtonRingAlphaPercent / 100f)
+ .coerceIn(0f, 1f)
val maxX = (maxWidth - ballSize).coerceAtLeast(0.dp)
val maxY = (maxHeight - ballSize).coerceAtLeast(0.dp)
val currentX =
@@ -507,14 +599,24 @@ class VirtualButtonBar(
minHeight = ballSize,
insideMargin = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(
- color = Color.Black.copy(alpha = 0.24f),
+ color = Color.Black.copy(alpha = backgroundAlpha),
),
) {
Box(
modifier = Modifier
.size(ringSize)
.clip(CircleShape)
- .border(ringWidth, Color.White, CircleShape),
+ .then(
+ if (ringAlpha > 0f) {
+ Modifier.border(
+ ringWidth,
+ Color.White.copy(alpha = ringAlpha),
+ CircleShape,
+ )
+ } else {
+ Modifier
+ }
+ ),
)
}
diff --git a/app/src/main/res/drawable/text_select_handle_left_material.xml b/app/src/main/res/drawable/text_select_handle_left_material.xml
new file mode 100644
index 0000000..576ff4a
--- /dev/null
+++ b/app/src/main/res/drawable/text_select_handle_left_material.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/text_select_handle_right_material.xml b/app/src/main/res/drawable/text_select_handle_right_material.xml
new file mode 100644
index 0000000..d049d3a
--- /dev/null
+++ b/app/src/main/res/drawable/text_select_handle_right_material.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5ef68b8..d564a9f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "9.1.1"
+agp = "9.2.0"
bcpkixJdk18on = "1.83"
boringssl = "20251124"
conscryptAndroid = "2.5.3"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 816b33d..266eabf 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
#Thu Mar 19 08:59:02 HKT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME