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, + ); + }); +}