feat: more options for virtual buttons

This commit is contained in:
Miuzarte
2026-04-22 23:15:44 +08:00
parent c370b84894
commit 41856adc50
47 changed files with 8279 additions and 1192 deletions

View File

@@ -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 {

View File

@@ -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<String, Int>().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 <kHome> keypad home key
// t_K3 <kPageUp> keypad page-up key
// t_K4 <kEnd> keypad end key
// t_K5 <kPageDown> 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"
}
}

View File

@@ -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
}
}
}

View File

@@ -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<TerminalRow?>
/** 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
}
}

View File

@@ -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()
)
}
}

View File

@@ -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:<red>/<green>/<blue> where <red>, <green>, <blue> := 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()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}

View File

@@ -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?)
}

View File

@@ -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()
}
}

View File

@@ -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<IntArray>, 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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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?)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -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 },

View File

@@ -1,11 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.runtime.staticCompositionLocalOf
class ServerPicker(
val pick: () -> Unit,
)
val LocalServerPicker = staticCompositionLocalOf<ServerPicker> {
error("No ServerPicker provided")
}

View File

@@ -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<ServerPicker> {
error("No ServerPicker provided")
}
val LocalTerminalFontPicker = staticCompositionLocalOf<TerminalFontPicker> {
error("No TerminalFontPicker provided")
}

View File

@@ -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",

View File

@@ -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 = "选择字体",
)
}
}
},
)
}
}
}
}

View File

@@ -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

View File

@@ -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<AdbSocketStream?>(null) }
var terminalInputView by remember { mutableStateOf<TerminalInputView?>(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<TerminalView?>(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<TerminalSession>(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,
)
}
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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<Int, Pair<Int, Int>>,
private val pointerLabels: LinkedHashMap<Int, Int>,
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,

View File

@@ -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<Bundle>>(
bundleField(RSA_PRIVATE_KEY) { it.rsaPrivateKey },
bundleField(RSA_PUBLIC_KEY_X509) { it.rsaPublicKeyX509 },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)

View File

@@ -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<BundleField<Bundle>>(
// 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<Bundle> = 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),

View File

@@ -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<String>
) {
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<Int>
) {
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<Boolean>
) {
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"
}
}

View File

@@ -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<Bundle>>(
bundleField(QUICK_DEVICES_LIST) { it.quickDevicesList },
bundleField(QUICK_CONNECT_INPUT) { it.quickConnectInput },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)

View File

@@ -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<Bundle>>(
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<Bundle> = 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,

View File

@@ -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<String>,
@@ -13,10 +33,91 @@ data class MultiGroupsDropdownGroup(
val onSelectedIndexChange: (Int) -> Unit,
)
@Composable
fun MultiGroupsDropdownPreference(
title: String,
modifier: Modifier = Modifier,
summary: String? = null,
groups: List<MultiGroupsDropdownGroup>,
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<MultiGroupsDropdownGroup>,
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,
)

View File

@@ -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<VirtualButtonAction>,
private val moreActions: List<VirtualButtonAction>,
) {
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
}
),
)
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M52.3,1.6c-5.7,2.1 -12.9,8.6 -16,14.8 -2.2,4.1 -2.8,6.9 -3.1,14.3 -0.6,12.6 1.3,17.8 9.3,25.8 8,8 13.2,9.9 25.8,9.3 11.1,-0.5 17.3,-3.2 23.5,-10.3 6.5,-7.4 7.2,-10.8 7.2,-34.7l0,-20.8 -21.2,0.1c-16.1,-0 -22.3,0.4 -25.5,1.5z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M33,20.8c0,23.9 0.7,27.3 7.2,34.7 6.2,7.1 12.4,9.8 23.5,10.3 12.6,0.6 17.8,-1.3 25.8,-9.3 8,-8 9.9,-13.2 9.3,-25.8 -0.5,-11.1 -3.2,-17.3 -10.3,-23.5 -7.4,-6.5 -10.8,-7.2 -34.7,-7.2l-20.8,-0 0,20.8z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>