fix(terminal): send SGR mouse wheel reports with the button codes app… (#15817)

* fix(terminal): send SGR mouse wheel reports with the button codes apps expect

xterm.dart 4.0.0 encodes the wheel buttons as 64+4..64+7 rather than
64+0..64+3, so the low bits land on the modifier field and every wheel
report the terminal emits reads as wheel-with-Shift. Strict full-screen
applications reject the modified event, which is why neither the mouse
wheel nor the trackpad scrolls anything once the peer application takes
over the alternate screen.

Install a mouse handler that keeps every upstream reporting decision and
only re-encodes the wheel buttons as 64..67. Non-wheel reports pass
through untouched, and the emitted bytes stay identical once upstream
ships the same fix, so this can be dropped without a behavior change.

Upstream: TerminalStudio/xterm.dart#238

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(terminal): correct the wheel report row, drop the wasted report build

Address review feedback on the wheel button fix:

- The X10/utf row was encoded as `32 + y + 1` while y is already 1-based, so
  every normal-mode report pointed one row too low and the `y > limit` guard
  disagreed with what it emitted.
- Gate the wheel path on `mouseMode.reportScroll` and the button state instead
  of building and discarding a full report string from `defaultMouseHandler`
  on every scroll tick. This also makes the hardcoded SGR 'M' provably right,
  since a wheel release now returns before the report is built.
- Derive the wire code as `id - 4` and drop `_wheelButtonId`, whose `default`
  branch was unreachable and defeated enum exhaustiveness.
- Assign `mouseHandler` after construction so the `Terminal(...)` line stays
  untouched.

Cover the utf, urxvt, null-byte overflow and click-only branches, and assert
that TerminalModel actually installs the handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
RustDesk
2026-08-11 13:54:57 +08:00
committed by GitHub
parent 947cb3f17b
commit ff07ff7f13
4 changed files with 175 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import 'package:xterm/xterm.dart';
import 'input_modifier_utils.dart';
import 'model.dart';
import 'platform_model.dart';
import 'terminal_mouse_handler.dart';
class TerminalModel with ChangeNotifier {
final String id; // peer id
@@ -129,6 +130,7 @@ class TerminalModel with ChangeNotifier {
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = Terminal(maxLines: 10000);
terminal.mouseHandler = const WheelButtonFixMouseHandler();
terminalController = TerminalController();
// Setup terminal callbacks

View File

@@ -0,0 +1,42 @@
import 'package:xterm/xterm.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler();
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final x = event.position.x + 1;
final y = event.position.y + 1;
switch (event.state.mouseReportMode) {
case MouseReportMode.normal:
case MouseReportMode.utf:
final limit =
event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015;
final col = x > limit ? '\x00' : String.fromCharCode(32 + x);
final row = y > limit ? '\x00' : String.fromCharCode(32 + y);
return '\x1b[M${String.fromCharCode(32 + button)}$col$row';
case MouseReportMode.sgr:
return '\x1b[<$button;$x;${y}M';
case MouseReportMode.urxvt:
return '\x1b[${32 + button};$x;${y}M';
}
}
}

View File

@@ -3,6 +3,7 @@ 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';
import 'package:xterm/xterm.dart';
class _FakeFFI implements FFI {
@override
@@ -48,4 +49,20 @@ void main() {
expect(clearedCtrlLock, isFalse);
expect(model.debugBufferedInputCount, 0);
});
test('builds its terminal with the wheel button fix', () {
final model = TerminalModel(_FakeFFI());
addTearDown(model.dispose);
final captured = <String>[];
model.terminal.onOutput = captured.add;
model.terminal.write('\x1b[?1000h\x1b[?1006h');
model.terminal.mouseInput(
TerminalMouseButton.wheelUp,
TerminalMouseButtonState.down,
const CellOffset(10, 5),
);
expect(captured.single, '\x1b[<64;11;6M');
});
}

View File

@@ -0,0 +1,114 @@
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xterm/xterm.dart';
void main() {
late Terminal terminal;
late List<String> output;
setUp(() {
output = <String>[];
terminal = Terminal(mouseHandler: const WheelButtonFixMouseHandler())
..onOutput = output.add;
});
String? report(
TerminalMouseButton button, [
TerminalMouseButtonState state = TerminalMouseButtonState.down,
CellOffset position = const CellOffset(10, 5),
]) {
output.clear();
terminal.mouseInput(button, state, position);
return output.isEmpty ? null : output.single;
}
test('reports SGR wheel buttons without the Shift modifier bit', () {
terminal.write('\x1b[?1000h\x1b[?1006h');
expect(report(TerminalMouseButton.wheelUp), '\x1b[<64;11;6M');
expect(report(TerminalMouseButton.wheelDown), '\x1b[<65;11;6M');
expect(report(TerminalMouseButton.wheelLeft), '\x1b[<66;11;6M');
expect(report(TerminalMouseButton.wheelRight), '\x1b[<67;11;6M');
});
test('reports normal-encoding wheel buttons in the 64..67 range', () {
terminal.write('\x1b[?1000h');
expect(
report(TerminalMouseButton.wheelUp),
'\x1b[M${String.fromCharCode(32 + 64)}'
'${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}',
);
expect(
report(TerminalMouseButton.wheelDown),
'\x1b[M${String.fromCharCode(32 + 65)}'
'${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}',
);
});
test('reports utf-encoding wheel buttons beyond the normal-mode range', () {
terminal.write('\x1b[?1000h\x1b[?1005h');
expect(
report(
TerminalMouseButton.wheelDown,
TerminalMouseButtonState.down,
const CellOffset(400, 300),
),
'\x1b[M${String.fromCharCode(32 + 65)}'
'${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}',
);
});
test('reports urxvt-encoding wheel buttons shifted by 32', () {
terminal.write('\x1b[?1000h\x1b[?1015h');
expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M');
expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M');
});
test('sends a null byte for coordinates past the encoding limit', () {
terminal.write('\x1b[?1000h');
expect(
report(
TerminalMouseButton.wheelUp,
TerminalMouseButtonState.down,
const CellOffset(300, 300),
),
'\x1b[M${String.fromCharCode(32 + 64)}\x00\x00',
);
});
test('leaves non-wheel buttons to the upstream handler', () {
terminal.write('\x1b[?1000h\x1b[?1006h');
expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M');
expect(report(TerminalMouseButton.middle), '\x1b[<1;11;6M');
expect(
report(TerminalMouseButton.right, TerminalMouseButtonState.up),
'\x1b[<2;11;6m',
);
});
test('stays silent when the peer has not enabled mouse reporting', () {
expect(report(TerminalMouseButton.wheelDown), isNull);
expect(report(TerminalMouseButton.left), isNull);
});
test('stays silent for the wheel in click-only mode', () {
terminal.write('\x1b[?9h\x1b[?1006h');
expect(report(TerminalMouseButton.wheelDown), isNull);
expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M');
});
test('does not report wheel button releases', () {
terminal.write('\x1b[?1000h\x1b[?1006h');
expect(
report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up),
isNull,
);
});
}