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:
茂之钳
2026-05-24 08:38:04 +00:00
parent 35f3200d6b
commit 008f44fac5
@@ -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()
}
)
}
}
}
}