Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/nocterm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
36 changes: 35 additions & 1 deletion lib/src/binding/terminal_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<int> bytes) {
if (rootElement == null) return false;
return _findAndCallInputListener(rootElement!, bytes);
}

bool _findAndCallInputListener(Element element, List<int> 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
Expand Down
69 changes: 69 additions & 0 deletions lib/src/components/input_listener.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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<int> bytes) {
return component.onInput(bytes);
}
}
11 changes: 6 additions & 5 deletions lib/src/components/render_paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions lib/src/framework/terminal_canvas.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
library;

import 'dart:math' as math;

import 'package:characters/characters.dart';
import 'package:nocterm/src/rectangle.dart';

Expand All @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions lib/src/keyboard/input_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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]) {
Expand Down
38 changes: 38 additions & 0 deletions lib/src/test/nocterm_test_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding {
/// Stream controller for simulating keyboard events
final _testKeyboardController = StreamController<KeyboardEvent>.broadcast();

/// Queue of pending raw input byte batches to be dispatched.
final _pendingRawInputs = <List<int>>[];

/// Queue of pending keyboard events to be processed
final _pendingKeyboardEvents = <KeyboardEvent>[];

Expand All @@ -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);
Expand Down Expand Up @@ -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<int> bytes) {
_pendingRawInputs.add(bytes);
}

/// Simulate keyboard input
void sendKeyboardEvent(KeyboardEvent event) {
_pendingKeyboardEvents.add(event);
Expand Down Expand Up @@ -257,6 +273,28 @@ class NoctermTestBinding extends NoctermBinding with SchedulerBinding {
}

/// Dispatch a keyboard event to an element and its children
bool _dispatchRawInput(List<int> bytes) {
if (rootElement == null) return false;
return _findAndCallInputListener(rootElement!, bytes);
}

bool _findAndCallInputListener(Element element, List<int> 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
Expand Down
6 changes: 6 additions & 0 deletions lib/src/test/nocterm_tester.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ class NoctermTester {
Future<void> sendArrowLeft() => sendKey(LogicalKey.arrowLeft);
Future<void> sendArrowRight() => sendKey(LogicalKey.arrowRight);

/// Send raw stdin bytes, dispatched to [InputListener] widgets.
Future<void> sendRawBytes(List<int> bytes) async {
_binding.sendRawInput(bytes);
await pump();
}

/// Send a mouse event
Future<void> sendMouseEvent(MouseEvent event) async {
_binding.sendMouseEvent(event);
Expand Down
Loading
Loading