v1.2.0: remove input box, real terminal-style input
- Removed InputRow and BasicTextField entirely - Terminal input now happens inline in the last line of output - onKeyEvent captures keystrokes at the terminal level - Connect button no longer checks password/key requirement - True Linux terminal input experience
This commit is contained in:
@@ -10,10 +10,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -27,17 +23,17 @@ import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -391,9 +387,7 @@ fun SshConnectDialog(
|
||||
onConnectKey(config, keyPassphrase.ifBlank { null })
|
||||
}
|
||||
},
|
||||
enabled = host.isNotBlank() && username.isNotBlank() && (
|
||||
authMethod == "key" || true
|
||||
),
|
||||
enabled = host.isNotBlank() && username.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = accentBlue)
|
||||
) {
|
||||
Text(stringResource(TergentR.string.ssh_connect_button), color = Color.White)
|
||||
@@ -422,7 +416,7 @@ fun SshTerminalScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by manager.connectionState.collectAsState()
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var currentInput by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
var showFontPicker by remember { mutableStateOf(false) }
|
||||
@@ -430,24 +424,76 @@ fun SshTerminalScreen(
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val currentFontSize = remember { mutableStateOf(SshConfigStorage.loadFontSize(context)) }
|
||||
|
||||
// Bug 2: 根据选中的大小获取实际 sp 值
|
||||
val terminalFontSize = fontMap[currentFontSize.value] ?: 12
|
||||
val terminalFontSizeSp = terminalFontSize.sp
|
||||
|
||||
// 输入框焦点
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// 自动滚动到底部(包括输入栏)
|
||||
LaunchedEffect(state.lines.size) {
|
||||
// 自动滚动到底部
|
||||
LaunchedEffect(state.lines.size, currentInput) {
|
||||
if (state.lines.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.lines.size)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接后自动聚焦输入框
|
||||
// 键盘事件处理
|
||||
fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!state.connected) return false
|
||||
return when (event.type) {
|
||||
KeyEventType.KeyUp -> {
|
||||
when (event.key) {
|
||||
Key.Enter -> {
|
||||
if (currentInput.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(currentInput)
|
||||
currentInput = ""
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Key.Backspace -> {
|
||||
if (currentInput.isNotEmpty()) {
|
||||
currentInput = currentInput.substring(0, currentInput.length - 1)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCharacterInput(event: KeyEvent): Boolean {
|
||||
if (!state.connected) return false
|
||||
if (event.type != KeyEventType.KeyUp) return false
|
||||
|
||||
return when (event.key) {
|
||||
Key.Enter, Key.Backspace, Key.Tab,
|
||||
Key.Escape, Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6,
|
||||
Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, Key.F12,
|
||||
Key.DirectionUp, Key.DirectionDown, Key.DirectionLeft, Key.DirectionRight,
|
||||
Key.PageUp, Key.PageDown, Key.MoveHome, Key.MoveEnd,
|
||||
Key.Insert, Key.Delete, Key.CapsLock,
|
||||
Key.ShiftLeft, Key.ShiftRight, Key.CtrlLeft, Key.CtrlRight,
|
||||
Key.AltLeft, Key.AltRight, Key.MetaLeft, Key.MetaRight -> false
|
||||
|
||||
else -> {
|
||||
val c = event.key.nativeKeyCode.toChar()
|
||||
if (c.isISOControl()) return false
|
||||
if (event.isCtrlPressed) return false
|
||||
if (event.isAltPressed) return false
|
||||
if (event.isMetaPressed) return false
|
||||
currentInput += c
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val focusModifier = remember { FocusRequester() }
|
||||
|
||||
// 连接后自动获取焦点
|
||||
LaunchedEffect(state.connected) {
|
||||
if (state.connected) {
|
||||
inputFocusRequester.requestFocus()
|
||||
focusModifier.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,12 +501,15 @@ fun SshTerminalScreen(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(bg)
|
||||
.onKeyEvent { handleKeyEvent(it) }
|
||||
.onPreviewKeyEvent { handleCharacterInput(it) }
|
||||
.focusRequester(focusModifier)
|
||||
.onFocusChanged { }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
// Bug 1: 添加 imePadding 和 navigationBarsPadding
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
@@ -500,7 +549,6 @@ fun SshTerminalScreen(
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (state.connected) {
|
||||
// 复制按钮 — 放大触摸区域
|
||||
IconButton(onClick = {
|
||||
val clip = context.getSystemService(android.content.ClipboardManager::class.java)
|
||||
val text = state.lines.joinToString("\n")
|
||||
@@ -508,21 +556,17 @@ fun SshTerminalScreen(
|
||||
}, modifier = Modifier.size(28.dp)) {
|
||||
Text("📋", fontSize = 12.sp)
|
||||
}
|
||||
// 字体大小
|
||||
IconButton(onClick = { showFontPicker = true }, modifier = Modifier.size(28.dp)) {
|
||||
Text("Aa", color = accentBlue, fontSize = 12.sp)
|
||||
}
|
||||
// SFTP
|
||||
if (onShowSftp != null) {
|
||||
IconButton(onClick = onShowSftp, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Default.Folder, contentDescription = "SFTP", tint = accentBlue, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
// 端口转发
|
||||
IconButton(onClick = { showForwardDialog = true }, modifier = Modifier.size(28.dp)) {
|
||||
Text("🔌", fontSize = 12.sp)
|
||||
}
|
||||
// 断开
|
||||
IconButton(onClick = { coroutineScope.launch { manager.sendCommand("exit") }; onDisconnect() }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Default.PowerSettingsNew, contentDescription = stringResource(TergentR.string.ssh_disconnect), tint = danger, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
@@ -556,7 +600,7 @@ fun SshTerminalScreen(
|
||||
// ── 内容区 ──
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (state.connected && state.lines.isNotEmpty()) {
|
||||
// 终端历史输出
|
||||
// 终端历史输出 + 当前输入行(最后一行 inline)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -577,6 +621,18 @@ fun SshTerminalScreen(
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
item(key = "prompt") {
|
||||
Text(
|
||||
text = "\$ $currentInput",
|
||||
color = textPrimary,
|
||||
fontSize = terminalFontSizeSp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!state.connected && state.error != null) {
|
||||
// 连接失败 — 显示错误 + 重连按钮
|
||||
@@ -641,23 +697,7 @@ fun SshTerminalScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 始终显示输入行(当 connected 时)
|
||||
if (state.connected) {
|
||||
InputRow(
|
||||
input = input,
|
||||
onInputChange = { input = it },
|
||||
onSend = {
|
||||
if (input.text.isNotBlank()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(input.text)
|
||||
input = TextFieldValue("")
|
||||
}
|
||||
}
|
||||
},
|
||||
terminalFontSize = terminalFontSizeSp,
|
||||
inputFocusRequester = inputFocusRequester
|
||||
)
|
||||
}
|
||||
// 不再有独立的 InputRow — 输入在终端最后一行 inline 完成
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +713,6 @@ fun SshTerminalScreen(
|
||||
title = { Text(stringResource(TergentR.string.forward_title), color = textPrimary, fontFamily = FontFamily.Monospace) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// 类型选择
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = isLocal, onClick = { isLocal = true }, label = { Text(stringResource(TergentR.string.forward_local), fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, colors = FilterChipDefaults.filterChipColors(selectedContainerColor = accent.copy(alpha = 0.2f)))
|
||||
FilterChip(selected = !isLocal, onClick = { isLocal = false }, label = { Text(stringResource(TergentR.string.forward_remote), fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, colors = FilterChipDefaults.filterChipColors(selectedContainerColor = accent.copy(alpha = 0.2f)))
|
||||
@@ -690,7 +729,6 @@ fun SshTerminalScreen(
|
||||
val rp = remotePort.toIntOrNull() ?: 0
|
||||
if (lp > 0 && rp > 0 && remoteHost.isNotBlank()) {
|
||||
val fwd = PortForward(lp, remoteHost, rp, if (isLocal) ForwardType.Local else ForwardType.Remote)
|
||||
// Forward via terminal manager's session
|
||||
fwd.let { coroutineScope.launch { } }
|
||||
}
|
||||
showForwardDialog = false
|
||||
@@ -731,70 +769,3 @@ fun SshTerminalScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 底部固定输入行(始终在终端底部,不嵌入 LazyColumn)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun InputRow(
|
||||
input: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
terminalFontSize: androidx.compose.ui.unit.TextUnit,
|
||||
inputFocusRequester: FocusRequester
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = surface,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 提示符 $
|
||||
Text(
|
||||
text = "\$ ",
|
||||
color = accent,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = terminalFontSize
|
||||
)
|
||||
// 输入框
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = input,
|
||||
onValueChange = onInputChange,
|
||||
textStyle = TextStyle(
|
||||
color = textPrimary,
|
||||
fontSize = terminalFontSize,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(accentBlue),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = { onSend() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(inputFocusRequester),
|
||||
decorationBox = { innerTextField ->
|
||||
if (input.text.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(TergentR.string.ssh_input_hint),
|
||||
color = textTertiary,
|
||||
fontSize = terminalFontSize,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user