From ff07ff7f13a7c4a350519243b803759207978817 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:54:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(terminal):=20send=20SGR=20mouse=20wheel=20r?= =?UTF-8?q?eports=20with=20the=20button=20codes=20app=E2=80=A6=20(#15817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- flutter/lib/models/terminal_model.dart | 2 + .../lib/models/terminal_mouse_handler.dart | 42 +++++++ .../test/terminal_model_lifecycle_test.dart | 17 +++ flutter/test/terminal_mouse_handler_test.dart | 114 ++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 flutter/lib/models/terminal_mouse_handler.dart create mode 100644 flutter/test/terminal_mouse_handler_test.dart diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 6f179afe2..0472cb483 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -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 diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart new file mode 100644 index 000000000..a6a617488 --- /dev/null +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -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'; + } + } +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart index d00646b2b..5581886b7 100644 --- a/flutter/test/terminal_model_lifecycle_test.dart +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -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 = []; + 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'); + }); } diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart new file mode 100644 index 000000000..3fae7f71d --- /dev/null +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -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 output; + + setUp(() { + output = []; + 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, + ); + }); +}