diff --git a/lib/nocterm.dart b/lib/nocterm.dart index 3e8ad908..3d72b18e 100644 --- a/lib/nocterm.dart +++ b/lib/nocterm.dart @@ -26,6 +26,7 @@ export 'src/components/builder.dart'; export 'src/components/focusable.dart'; export 'src/components/focus_scope.dart'; export 'src/components/block_focus.dart'; +export 'src/components/input_listener.dart'; export 'src/components/scroll_controller.dart'; export 'src/components/auto_scroll_controller.dart'; export 'src/components/single_child_scroll_view.dart'; diff --git a/lib/src/binding/terminal_binding.dart b/lib/src/binding/terminal_binding.dart index 00d46c54..732b6a54 100644 --- a/lib/src/binding/terminal_binding.dart +++ b/lib/src/binding/terminal_binding.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:nocterm/nocterm.dart'; import 'package:nocterm/src/framework/terminal_canvas.dart'; +import 'package:nocterm/src/image/image_cleanup.dart'; import 'package:nocterm/src/navigation/render_theater.dart'; import 'package:nocterm/src/rendering/scrollable_render_object.dart'; -import 'package:nocterm/src/image/image_cleanup.dart'; import '../backend/terminal.dart' as term; import '../buffer.dart' as buf; @@ -230,6 +230,15 @@ class TerminalBinding extends NoctermBinding } _lastInputTime = now; + // Check for raw input listeners before parsing. If an + // InputListener consumes the bytes, skip the parser. + if (_dispatchRawInput(bytes)) { + if (buildOwner.hasDirtyElements) { + scheduleFrame(); + } + return; + } + // Parse the bytes and process ALL events in the buffer _inputParser.addBytes(bytes); @@ -656,6 +665,31 @@ class TerminalBinding extends NoctermBinding return result; } + /// Dispatch raw stdin bytes to [InputListenerElement]s in the tree. + /// Returns true if any listener consumed the bytes. + bool _dispatchRawInput(List bytes) { + if (rootElement == null) return false; + return _findAndCallInputListener(rootElement!, bytes); + } + + bool _findAndCallInputListener(Element element, List bytes) { + // Respect BlockFocus — same as _dispatchKeyToElement. + if (element is BlockFocusElement && element.isBlocking) { + return true; + } + + bool handled = false; + element.visitChildren((child) { + if (!handled) { + handled = _findAndCallInputListener(child, bytes); + } + }); + if (!handled && element is InputListenerElement) { + handled = element.handleRawInput(bytes); + } + return handled; + } + /// Dispatch a keyboard event to an element and its children bool _dispatchKeyToElement(Element element, KeyboardEvent event) { // Check if this element is a BlockFocus that's blocking diff --git a/lib/src/components/input_listener.dart b/lib/src/components/input_listener.dart new file mode 100644 index 00000000..113ef8b5 --- /dev/null +++ b/lib/src/components/input_listener.dart @@ -0,0 +1,69 @@ +import '../framework/framework.dart'; + +/// A callback that receives raw stdin bytes before nocterm's input +/// parser processes them. +/// +/// Return `true` to consume the bytes (nocterm skips parsing for +/// this batch). Return `false` to let nocterm handle them normally. +typedef RawInputHandler = bool Function(List bytes); + +/// A component that intercepts raw stdin bytes before they reach +/// nocterm's [InputParser]. +/// +/// Unlike [Focusable], which receives parsed keyboard events, +/// [InputListener] operates on the raw byte stream. The two +/// widgets serve different purposes: +/// - Use [Focusable] when you need semantic key events (arrow up, +/// Ctrl+C, printable characters). +/// - Use [InputListener] when you need byte-for-byte passthrough. +/// +/// Example: +/// ```dart +/// InputListener( +/// onInput: (bytes) { +/// return true; // return true to consume (don't parse), false for +/// // normal processing with nocterm's input parser. +/// }, +/// child: Text('This widget can intercept raw input bytes.'), +/// ) +/// ``` +class InputListener extends StatelessComponent { + const InputListener({ + super.key, + required this.onInput, + required this.child, + }); + + /// Callback invoked with raw stdin bytes. + /// + /// Return `true` to consume the bytes (nocterm will not parse + /// them into keyboard/mouse events). Return `false` to let + /// nocterm process them normally. + final RawInputHandler onInput; + + /// The child component to wrap. + final Component child; + + @override + InputListenerElement createElement() => InputListenerElement(this); + + @override + Component build(BuildContext context) => child; +} + +/// Element for [InputListener]. +/// +/// The binding recognises this element type during raw input +/// dispatch, just as it recognises [FocusableElement] during +/// keyboard event dispatch. +class InputListenerElement extends StatelessElement { + InputListenerElement(InputListener super.component); + + @override + InputListener get component => super.component as InputListener; + + /// Forward raw bytes to the widget's callback. + bool handleRawInput(List bytes) { + return component.onInput(bytes); + } +} diff --git a/lib/src/components/render_paragraph.dart b/lib/src/components/render_paragraph.dart index 66fa3f23..4a556a08 100644 --- a/lib/src/components/render_paragraph.dart +++ b/lib/src/components/render_paragraph.dart @@ -229,15 +229,16 @@ class RenderParagraph extends RenderObject with Selectable { canvas, Offset(xOffset, offset.dy + i), lineSegments, lineText, i); } else { // Paint each segment with its style - double currentX = xOffset; + // Snap to integer cell boundary — fractional offsets from + // alignment calculations (e.g. center: (width - text) / 2) + // cause rounding drift across styled segments. + int currentX = xOffset.round(); for (final segment in lineSegments) { canvas.drawText( - Offset(currentX, offset.dy + i), + Offset(currentX.toDouble(), offset.dy + i), segment.text, style: segment.style, ); - // Move x position by the actual display width of the segment text - // This accounts for wide characters like emojis and CJK characters currentX += UnicodeWidth.stringWidth(segment.text); } } @@ -277,7 +278,7 @@ class RenderParagraph extends RenderObject with Selectable { final hasLineSelection = selRangeEnd > lineStartOffset && selRangeStart < lineEndOffset; - double currentX = offset.dx; + double currentX = offset.dx.roundToDouble(); int charOffset = lineStartOffset; for (final segment in segments) { diff --git a/lib/src/framework/terminal_canvas.dart b/lib/src/framework/terminal_canvas.dart index 821cb211..fb79937a 100644 --- a/lib/src/framework/terminal_canvas.dart +++ b/lib/src/framework/terminal_canvas.dart @@ -1,6 +1,7 @@ library; import 'dart:math' as math; + import 'package:characters/characters.dart'; import 'package:nocterm/src/rectangle.dart'; @@ -16,6 +17,21 @@ class TerminalCanvas { final Buffer _buffer; final Rect area; + /// Set a cell directly at the given position without any text + /// layout, width measurement, or style blending. + /// + /// Use this when you already have a pre-laid-out cell grid + /// and want cell-perfect placement without nocterm re-interpreting + /// character widths. + /// + /// [x] and [y] are in local coordinates (relative to this + /// canvas's area). Out-of-bounds writes are silently ignored. + void setRaw(int x, int y, Cell cell) { + final cellX = area.left.round() + x; + final cellY = area.top.round() + y; + _buffer.setCell(cellX, cellY, cell); + } + /// Blends a style with the background color from the existing cell if needed. /// /// This handles alpha blending for semi-transparent colors: diff --git a/lib/src/keyboard/input_parser.dart b/lib/src/keyboard/input_parser.dart index 563dfafb..e75f4093 100644 --- a/lib/src/keyboard/input_parser.dart +++ b/lib/src/keyboard/input_parser.dart @@ -353,8 +353,10 @@ class InputParser { if (result != null) return result; } - // Arrow keys: ESC [ A/B/C/D (3 bytes) - if (_buffer.length == 3) { + // Arrow keys and other 3-byte CSI finals: ESC [ A/B/C/D/H/F/Z + // Check byte[2] directly — buffer may contain trailing bytes from + // the next keystroke, so we can't require length == 3. + { switch (_buffer[2]) { case 0x41: return ( @@ -690,7 +692,8 @@ class InputParser { } (KeyboardEvent, int)? _parseSS3Sequence() { - if (_buffer.length != 3) return null; + // Don't require exact length 3 — buffer may have trailing bytes. + if (_buffer.length < 3) return null; // F1-F4 use SS3 sequences (all are 3 bytes: ESC O X) switch (_buffer[2]) { diff --git a/lib/src/test/nocterm_test_binding.dart b/lib/src/test/nocterm_test_binding.dart index 8ffb6df5..c404fa6e 100644 --- a/lib/src/test/nocterm_test_binding.dart +++ b/lib/src/test/nocterm_test_binding.dart @@ -39,6 +39,9 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding { /// Stream controller for simulating keyboard events final _testKeyboardController = StreamController.broadcast(); + /// Queue of pending raw input byte batches to be dispatched. + final _pendingRawInputs = >[]; + /// Queue of pending keyboard events to be processed final _pendingKeyboardEvents = []; @@ -58,6 +61,12 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding { await Future.delayed(duration); } + // Process any pending raw input batches. + while (_pendingRawInputs.isNotEmpty) { + final bytes = _pendingRawInputs.removeAt(0); + _dispatchRawInput(bytes); + } + // Process any pending keyboard events while (_pendingKeyboardEvents.isNotEmpty) { final event = _pendingKeyboardEvents.removeAt(0); @@ -103,6 +112,13 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding { } } + /// Simulate raw stdin bytes. Dispatched to [InputListenerElement]s + /// during [pump]. Unconsumed bytes are dropped (use + /// [sendKeyboardEvent] for parsed event testing). + void sendRawInput(List bytes) { + _pendingRawInputs.add(bytes); + } + /// Simulate keyboard input void sendKeyboardEvent(KeyboardEvent event) { _pendingKeyboardEvents.add(event); @@ -257,6 +273,28 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding { } /// Dispatch a keyboard event to an element and its children + bool _dispatchRawInput(List bytes) { + if (rootElement == null) return false; + return _findAndCallInputListener(rootElement!, bytes); + } + + bool _findAndCallInputListener(Element element, List bytes) { + if (element is BlockFocusElement && element.isBlocking) { + return true; + } + + bool handled = false; + element.visitChildren((child) { + if (!handled) { + handled = _findAndCallInputListener(child, bytes); + } + }); + if (!handled && element is InputListenerElement) { + handled = element.handleRawInput(bytes); + } + return handled; + } + bool _dispatchKeyToElement(Element element, KeyboardEvent event) { // Check if this element is a BlockFocus that's blocking // Import BlockFocusElement dynamically to avoid circular dependencies diff --git a/lib/src/test/nocterm_tester.dart b/lib/src/test/nocterm_tester.dart index 42a5c2a4..07966fa1 100644 --- a/lib/src/test/nocterm_tester.dart +++ b/lib/src/test/nocterm_tester.dart @@ -137,6 +137,12 @@ class NoctermTester { Future sendArrowLeft() => sendKey(LogicalKey.arrowLeft); Future sendArrowRight() => sendKey(LogicalKey.arrowRight); + /// Send raw stdin bytes, dispatched to [InputListener] widgets. + Future sendRawBytes(List bytes) async { + _binding.sendRawInput(bytes); + await pump(); + } + /// Send a mouse event Future sendMouseEvent(MouseEvent event) async { _binding.sendMouseEvent(event); diff --git a/test/input_listener_test.dart b/test/input_listener_test.dart new file mode 100644 index 00000000..14093daa --- /dev/null +++ b/test/input_listener_test.dart @@ -0,0 +1,155 @@ +import 'package:nocterm/nocterm.dart'; +import 'package:test/test.dart'; + +void main() { + late NoctermTester tester; + + tearDown(() { + tester.dispose(); + }); + + test('InputListener receives raw bytes via sendRawBytes', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + final received = >[]; + + await tester.pumpComponent( + InputListener( + onInput: (bytes) { + received.add(bytes); + return true; + }, + child: const Text('hello'), + ), + ); + + await tester.sendRawBytes([0x68, 0x69]); // 'hi' + + expect(received, hasLength(1)); + expect(received.first, equals([0x68, 0x69])); + }); + + test('returning true consumes bytes — Focusable not called', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + var focusableCalled = false; + + await tester.pumpComponent( + InputListener( + onInput: (bytes) => true, // consume everything + child: Focusable( + focused: true, + onKeyEvent: (event) { + focusableCalled = true; + return true; + }, + child: const Text('inner'), + ), + ), + ); + + // Send raw bytes for 'a' (0x61) + await tester.sendRawBytes([0x61]); + + expect(focusableCalled, isFalse); + }); + + test('InputListener with no raw input allows keyboard events', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + var focusableCalled = false; + + await tester.pumpComponent( + InputListener( + onInput: (bytes) => true, + child: Focusable( + focused: true, + onKeyEvent: (event) { + focusableCalled = true; + return true; + }, + child: const Text('inner'), + ), + ), + ); + + // Use sendKeyboardEvent (not sendRawBytes) — bypasses InputListener + await tester.sendKey(LogicalKey.keyA); + + expect(focusableCalled, isTrue); + }); + + test('nested InputListeners — deepest-first wins', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + var outerCalled = false; + var innerCalled = false; + + await tester.pumpComponent( + InputListener( + onInput: (bytes) { + outerCalled = true; + return true; + }, + child: InputListener( + onInput: (bytes) { + innerCalled = true; + return true; // consumed by inner + }, + child: const Text('nested'), + ), + ), + ); + + await tester.sendRawBytes([0x61]); + + expect(innerCalled, isTrue); + expect(outerCalled, isFalse); + }); + + test('BlockFocus prevents raw input reaching InputListener', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + var listenerCalled = false; + + await tester.pumpComponent( + BlockFocus( + blocking: true, + child: InputListener( + onInput: (bytes) { + listenerCalled = true; + return true; + }, + child: const Text('blocked'), + ), + ), + ); + + await tester.sendRawBytes([0x61]); + + expect(listenerCalled, isFalse); + }); + + test('multiple raw input batches each trigger callback', () async { + tester = await NoctermTester.create(size: const Size(40, 10)); + + final received = >[]; + + await tester.pumpComponent( + InputListener( + onInput: (bytes) { + received.add(List.of(bytes)); + return true; + }, + child: const Text('hello'), + ), + ); + + await tester.sendRawBytes([0x61]); // 'a' + await tester.sendRawBytes([0x62]); // 'b' + + expect(received, hasLength(2)); + expect(received[0], equals([0x61])); + expect(received[1], equals([0x62])); + }); +}