mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 06:21:02 +03:00
feat(terminal): add Ctrl and Alt toggles to mobile terminal keyboard (#15532)
* feat(terminal): add Ctrl toggle and Ctrl+X shortcut keys to mobile terminal floating keyboard Signed-off-by: dongrencd <dongrencd@users.noreply.github.com> * refactor(terminal): restructure keyboard layout with collapse button - Move | from Row1 position 3 to Row1 end (aligned with collapse button) - Remove ~ from Row2, add collapse button (∨/∧) after PgDn - Row3: conditional render, add ~ and -, remove trailing placeholders - Collapse state persisted via kOptionEnableShowTerminalCtrlKeys - Row3 defaults to collapsed for compact layout Signed-off-by: dongrencd <dongrencd@users.noreply.github.com> * fix(terminal): restore trailing placeholders in Row3 for alignment Row3 needs trailing placeholders to match Row1/Row2 width (348px) so Ctrl aligns with Tab in Row2 and Esc in Row1. Signed-off-by: dongrencd <dongrencd@users.noreply.github.com> * fix(terminal): update mobile keyboard layout per review Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): address mobile keyboard review regressions Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): preserve ctrl-j newline mapping on mobile Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): preserve pasted input with modifiers Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): harden mobile modifier and paste input Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): harden mobile paste shortcut handling Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> * fix(terminal): preserve unicode graphemes under ctrl * fix(terminal): avoid modifier scan for inactive locks * fix(terminal): keep default hardware paste shortcuts * fix(terminal): guard hardware paste with modifier locks * fix(terminal): update mobile key button color role --------- Signed-off-by: dongrencd <dongrencd@users.noreply.github.com> Signed-off-by: dong.ren.cd <dong.ren.cd@tcl.com> Co-authored-by: dongrencd <dongrencd@users.noreply.github.com> Co-authored-by: dong.ren.cd <dong.ren.cd@tcl.com>
This commit is contained in:
@@ -122,4 +122,394 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldApplyTerminalInputModifiers', () {
|
||||
test('accepts ordinary single-character keyboard input', () {
|
||||
expect(shouldApplyTerminalInputModifiers('a'), isTrue);
|
||||
expect(shouldApplyTerminalInputModifiers(' '), isTrue);
|
||||
expect(shouldApplyTerminalInputModifiers('/'), isTrue);
|
||||
});
|
||||
|
||||
test('accepts supplementary-plane single-character keyboard input', () {
|
||||
expect(shouldApplyTerminalInputModifiers('😀'), isTrue);
|
||||
});
|
||||
|
||||
test('rejects terminal control bytes and multi-character sequences', () {
|
||||
for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) {
|
||||
expect(
|
||||
shouldApplyTerminalInputModifiers(input),
|
||||
isFalse,
|
||||
reason: '${input.codeUnits} must not consume a one-shot modifier',
|
||||
);
|
||||
}
|
||||
expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('applyTerminalInputModifiers', () {
|
||||
test('keeps decomposed graphemes intact under Ctrl', () {
|
||||
const decomposedEAcute = 'e\u0301';
|
||||
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
decomposedEAcute,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
decomposedEAcute,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps non-ASCII graphemes intact under Ctrl', () {
|
||||
for (final input in ['é', '😀']) {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
input,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
input,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps Ctrl underscore to unit separator', () {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
'_',
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
'\x1F',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps the complete Ctrl symbol range', () {
|
||||
const mappings = {
|
||||
'[': '\x1B',
|
||||
r'\': '\x1C',
|
||||
']': '\x1D',
|
||||
'^': '\x1E',
|
||||
'_': '\x1F',
|
||||
'/': '\x1F',
|
||||
};
|
||||
|
||||
for (final entry in mappings.entries) {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
entry.key,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
entry.value,
|
||||
reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies Ctrl before Alt for combined modifiers', () {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
'b',
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B\x02',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('terminalPastePayload', () {
|
||||
test('wraps paste text when bracketed paste mode is active', () {
|
||||
expect(
|
||||
terminalPastePayload('d', bracketedPasteMode: true),
|
||||
'\x1B[200~d\x1B[201~',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a lone newline unchanged when bracketed paste is disabled', () {
|
||||
expect(
|
||||
terminalPastePayload('\n', bracketedPasteMode: false),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('prepareTerminalInputPayload', () {
|
||||
test('normalizes a mobile keyboard Enter to carriage return', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\n',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
),
|
||||
'\r',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Ctrl+J as line feed on mobile', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'j',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not apply Alt to a terminal control byte', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\x1B',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps large keyboard payloads unchanged when modifiers are inactive',
|
||||
() {
|
||||
final payload = 'd' * (1024 * 1024);
|
||||
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
payload,
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: false,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
),
|
||||
payload,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps decomposed graphemes intact with locked keyboard modifiers',
|
||||
() {
|
||||
const decomposedEAcute = 'e\u0301';
|
||||
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
decomposedEAcute,
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
decomposedEAcute,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves a lone pasted newline when modifiers are locked', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\n',
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps paste without applying locked modifiers', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'd',
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: true,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B[200~d\x1B[201~',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldHandleTerminalPasteShortcut', () {
|
||||
test(
|
||||
'keeps default xterm paste behavior when virtual modifiers are inactive',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('handles Ctrl+V and Meta+V when a virtual modifier lock is active',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: false,
|
||||
metaPressed: true,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('handles paste shortcut repeats while a virtual lock is active', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: true,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores key-up and unmodified V events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: false,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores paste shortcuts with extra modifiers', () {
|
||||
for (final state in [
|
||||
(control: true, meta: false, alt: true, shift: false),
|
||||
(control: true, meta: false, alt: false, shift: true),
|
||||
(control: false, meta: true, alt: false, shift: true),
|
||||
(control: true, meta: true, alt: false, shift: false),
|
||||
]) {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: state.control,
|
||||
metaPressed: state.meta,
|
||||
altPressed: state.alt,
|
||||
shiftPressed: state.shift,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-V key events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyC,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldClearTerminalModifiersWhenRow3Collapses', () {
|
||||
test('clears visible modifier state when expanded row is collapsed', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: true,
|
||||
willExpand: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not clear modifiers when row expands', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: false,
|
||||
willExpand: true,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('clears Alt state when expanded row is collapsed', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: true,
|
||||
willExpand: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
40
flutter/test/terminal_keyboard_utils_test.dart
Normal file
40
flutter/test/terminal_keyboard_utils_test.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('mobile terminal keyboard layout', () {
|
||||
test('keeps the latest key order from the reviewed PR layout', () {
|
||||
expect(
|
||||
terminalKeyboardRow1Keys,
|
||||
['Esc', '/', '|', 'Home', '↑', 'End', r'\'],
|
||||
);
|
||||
expect(
|
||||
terminalKeyboardRow2Keys,
|
||||
['Tab', 'Ctrl+C', '~', '←', '↓', '→'],
|
||||
);
|
||||
expect(
|
||||
terminalKeyboardRow3Keys,
|
||||
['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'],
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps two trailing Row3 placeholders for row alignment', () {
|
||||
expect(terminalKeyboardRow3TrailingPlaceholderCount, 2);
|
||||
});
|
||||
|
||||
test('keeps every expanded row aligned at 348dp', () {
|
||||
final rowWidths = [
|
||||
terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length),
|
||||
terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1),
|
||||
terminalKeyboardRowWidth(
|
||||
terminalKeyboardRow3Keys.length +
|
||||
terminalKeyboardRow3TrailingPlaceholderCount,
|
||||
),
|
||||
];
|
||||
|
||||
expect(terminalKeyboardKeyWidth, 48);
|
||||
expect(terminalKeyboardKeySpacing, 2);
|
||||
expect(rowWidths, everyElement(348));
|
||||
});
|
||||
});
|
||||
}
|
||||
51
flutter/test/terminal_model_lifecycle_test.dart
Normal file
51
flutter/test/terminal_model_lifecycle_test.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeFFI implements FFI {
|
||||
@override
|
||||
String id = 'test-peer';
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('ignores paste that completes after the terminal model is disposed',
|
||||
() async {
|
||||
final model = TerminalModel(_FakeFFI());
|
||||
final delayedClipboardText = Completer<String>();
|
||||
|
||||
// This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and
|
||||
// model are disposed before the asynchronous read supplies its text.
|
||||
final paste = delayedClipboardText.future.then(model.pasteText);
|
||||
model.dispose();
|
||||
delayedClipboardText.complete('late clipboard text');
|
||||
await paste;
|
||||
|
||||
expect(model.debugBufferedInputCount, 0);
|
||||
});
|
||||
|
||||
test('ignores terminal text input after the terminal model is disposed', () {
|
||||
final model = TerminalModel(_FakeFFI());
|
||||
var checkedCtrlLock = false;
|
||||
var clearedCtrlLock = false;
|
||||
|
||||
model.isCtrlLocked = () {
|
||||
checkedCtrlLock = true;
|
||||
return true;
|
||||
};
|
||||
model.clearCtrlLock = () {
|
||||
clearedCtrlLock = true;
|
||||
};
|
||||
|
||||
model.dispose();
|
||||
model.terminal.textInput('d');
|
||||
|
||||
expect(checkedCtrlLock, isFalse);
|
||||
expect(clearedCtrlLock, isFalse);
|
||||
expect(model.debugBufferedInputCount, 0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user