From 41856adc505edb361863c7f8aef3a2f7c5d6a304 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Wed, 22 Apr 2026 23:15:44 +0800 Subject: [PATCH] feat: more options for virtual buttons --- CHANGELOG.md | 13 +- README.md | 4 +- ScrcpyForAndroid.code-workspace | 3 + app/build.gradle.kts | 4 +- .../java/com/termux/terminal/KeyHandler.kt | 185 ++ .../main/java/com/termux/terminal/Logger.kt | 86 + .../com/termux/terminal/TerminalBuffer.kt | 534 +++++ .../termux/terminal/TerminalColorScheme.kt | 151 ++ .../com/termux/terminal/TerminalColors.kt | 108 + .../com/termux/terminal/TerminalEmulator.kt | 1838 +++++++++++++++++ .../com/termux/terminal/TerminalOutput.kt | 31 + .../java/com/termux/terminal/TerminalRow.kt | 279 +++ .../com/termux/terminal/TerminalSession.kt | 82 + .../termux/terminal/TerminalSessionClient.kt | 43 + .../java/com/termux/terminal/TextStyle.kt | 88 + .../main/java/com/termux/terminal/WcWidth.kt | 571 +++++ .../termux/view/GestureAndScaleRecognizer.kt | 90 + .../java/com/termux/view/TerminalRenderer.kt | 265 +++ .../main/java/com/termux/view/TerminalView.kt | 1222 +++++++++++ .../com/termux/view/TerminalViewClient.kt | 66 + .../support/PopupWindowCompatGingerbread.kt | 75 + .../view/textselection/CursorController.kt | 53 + .../TextSelectionCursorController.kt | 362 ++++ .../textselection/TextSelectionHandleView.kt | 330 +++ .../miuzarte/scrcpyforandroid/MainActivity.kt | 6 - .../scrcpyforandroid/pages/DeviceTabScreen.kt | 25 +- .../pages/FullscreenControlScreen.kt | 238 ++- .../pages/LocalServerPicker.kt | 11 - .../scrcpyforandroid/pages/MainScreen.kt | 93 + .../pages/ScrcpyAllOptionsScreen.kt | 14 +- .../scrcpyforandroid/pages/SettingsScreen.kt | 311 ++- .../scrcpyforandroid/pages/StreamScreen.kt | 23 +- .../scrcpyforandroid/pages/TerminalScreen.kt | 776 ++++--- .../scrcpyforandroid/scrcpy/ClientOptions.kt | 2 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 11 +- .../scrcpy/TouchEventHandler.kt | 116 +- .../scrcpyforandroid/storage/AdbClientData.kt | 6 +- .../scrcpyforandroid/storage/AppSettings.kt | 355 ++-- .../storage/PreferenceMigration.kt | 564 ----- .../scrcpyforandroid/storage/QuickDevices.kt | 6 +- .../scrcpyforandroid/storage/ScrcpyOptions.kt | 181 +- .../widgets/MultiGroupsDropdown.kt | 102 + .../widgets/VirtualButtons.kt | 124 +- .../text_select_handle_left_material.xml | 10 + .../text_select_handle_right_material.xml | 10 + gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 47 files changed, 8279 insertions(+), 1192 deletions(-) create mode 100644 app/src/main/java/com/termux/terminal/KeyHandler.kt create mode 100644 app/src/main/java/com/termux/terminal/Logger.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalBuffer.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalColorScheme.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalColors.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalEmulator.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalOutput.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalRow.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalSession.kt create mode 100644 app/src/main/java/com/termux/terminal/TerminalSessionClient.kt create mode 100644 app/src/main/java/com/termux/terminal/TextStyle.kt create mode 100644 app/src/main/java/com/termux/terminal/WcWidth.kt create mode 100644 app/src/main/java/com/termux/view/GestureAndScaleRecognizer.kt create mode 100644 app/src/main/java/com/termux/view/TerminalRenderer.kt create mode 100644 app/src/main/java/com/termux/view/TerminalView.kt create mode 100644 app/src/main/java/com/termux/view/TerminalViewClient.kt create mode 100644 app/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.kt create mode 100644 app/src/main/java/com/termux/view/textselection/CursorController.kt create mode 100644 app/src/main/java/com/termux/view/textselection/TextSelectionCursorController.kt create mode 100644 app/src/main/java/com/termux/view/textselection/TextSelectionHandleView.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/LocalServerPicker.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt create mode 100644 app/src/main/res/drawable/text_select_handle_left_material.xml create mode 100644 app/src/main/res/drawable/text_select_handle_right_material.xml 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 @@ Recent Tasks Streaming Scrcpy All Options - Terminal - Files + Terminal + Files Settings Multi Touch Test Virtual Buttons Reorder 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