feat(terminal): add two-row floating keyboard buttons for common commands (mobile only) (#13876)

* feat(terminal): add two-row floating keyboard buttons for common commands (mobile only)

* Fix missing newline at end of pl.rs

Add missing newline at the end of the file.
This commit is contained in:
alonginwind
2025-12-28 15:41:25 +08:00
committed by GitHub
parent 969ea28d06
commit 3384eda8b7
51 changed files with 247 additions and 30 deletions

View File

@@ -163,6 +163,7 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options
const String kOptionAllowWebSocket = "allow-websocket";

View File

@@ -71,6 +71,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _ignoreBatteryOpt = false;
var _enableStartOnBoot = false;
var _checkUpdateOnStartup = false;
var _showTerminalExtraKeys = false;
var _floatingWindowDisabled = false;
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
var _enableAbr = false;
@@ -139,6 +140,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
_allowAskForNoteAtEndOfConnection =
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
_showTerminalExtraKeys =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
}
@override
@@ -602,6 +605,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
);
}
enhancementsTiles.add(
SettingsTile.switchTile(
initialValue: _showTerminalExtraKeys,
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(translate('Show terminal extra keys')),
]),
onToggle: (bool v) async {
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
setState(() {
_showTerminalExtraKeys = newValue;
});
},
),
);
onFloatingWindowChanged(bool toValue) async {
if (toValue) {
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {

View File

@@ -9,6 +9,7 @@ import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
@@ -37,6 +38,9 @@ class _TerminalPageState extends State<TerminalPage>
double? _cellHeight;
double _sysKeyboardHeight = 0;
Timer? _keyboardDebounce;
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -75,10 +79,15 @@ class _TerminalPageState extends State<TerminalPage>
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
_showTerminalExtraKeys = mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
if (_showTerminalExtraKeys) {
_updateKeyboardHeight();
}
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@@ -107,15 +116,22 @@ class _TerminalPageState extends State<TerminalPage>
});
}
void _updateKeyboardHeight() {
if (_keyboardKey.currentContext != null) {
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
_keyboardHeight = renderBox.size.height;
}
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final realHeight = heightPx - _sysKeyboardHeight;
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
@override
@@ -134,39 +150,168 @@ class _TerminalPageState extends State<TerminalPage>
return Scaffold(
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
body: Stack(
children: [
Positioned.fill(
child: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
),
),
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
],
),
);
}
Widget _buildFloatingKeyboard() {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 0,
right: 0,
bottom: _sysKeyboardHeight,
child: Container(
key: _keyboardKey,
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
],
),
],
),
),
);
}
Widget _buildKeyButton(String label) {
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
switch (key) {
case 'Esc':
send = '\x1B';
break;
case 'Tab':
send = '\t';
break;
case 'Ctrl+C':
send = '\x03';
break;
case '':
send = '\x1B[A';
break;
case '':
send = '\x1B[B';
break;
case '':
send = '\x1B[C';
break;
case '':
send = '\x1B[D';
break;
case 'Home':
send = '\x1B[H';
break;
case 'End':
send = '\x1B[F';
break;
case 'PgUp':
send = '\x1B[5~';
break;
case 'PgDn':
send = '\x1B[6~';
break;
default:
send = key;
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {

View File

@@ -146,6 +146,10 @@ class TerminalModel with ChangeNotifier {
}
}
Future<void> sendVirtualKey(String data) async {
return _handleInput(data);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {