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
132 changes: 105 additions & 27 deletions lib/src/binding/terminal_binding.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:nocterm/nocterm.dart';
import 'package:nocterm/src/framework/terminal_canvas.dart';
Expand Down Expand Up @@ -47,6 +48,14 @@ class TerminalBinding extends NoctermBinding
final _mouseTracker = MouseTracker();
final _oscEventsController = StreamController<String>.broadcast();

/// Timer for detecting standalone ESC key press.
/// When ESC arrives as part of an incomplete escape sequence (e.g., ESC [),
/// we wait briefly to see if more bytes arrive. If not, we treat it as ESC.
Timer? _escapeTimer;

/// Duration to wait for additional escape sequence bytes before flushing ESC.
static const _escapeTimeoutDuration = Duration(milliseconds: 50);

// Event-driven loop support
final _eventLoopController = StreamController<void>.broadcast();
Stream<void> get _eventLoopStream => _eventLoopController.stream;
Expand Down Expand Up @@ -131,6 +140,10 @@ class TerminalBinding extends NoctermBinding

// Listen for input at the byte level for proper escape sequence handling
_inputSubscription = inputStream.listen((bytes) {
// Cancel any pending escape timer since new bytes arrived
_escapeTimer?.cancel();
_escapeTimer = null;

// Extract OSC sequences from input stream:
// - Normal mode: Terminal emulator responses (color queries, clipboard, etc.)
// - Shell mode: Above + custom protocol (e.g., OSC 9999 size updates)
Expand Down Expand Up @@ -201,6 +214,14 @@ class TerminalBinding extends NoctermBinding
scheduleFrame();
}

// Check for incomplete escape sequences and start timer if needed.
// This handles the case where ESC arrives as part of a potential escape
// sequence (e.g., ESC [) but no more bytes arrive. After a timeout,
// we flush the buffer as a standalone ESC key event.
if (_inputParser.hasIncompleteEscapeSequence()) {
_escapeTimer = Timer(_escapeTimeoutDuration, _flushEscapeSequence);
}

// Also add raw string for backwards compatibility
try {
final str = utf8.decode(bytes);
Expand All @@ -211,6 +232,25 @@ class TerminalBinding extends NoctermBinding
});
}

/// Flush an incomplete escape sequence as a standalone ESC key event.
/// Called by the escape timer when no additional bytes arrive.
void _flushEscapeSequence() {
_escapeTimer = null;
final escEvent = _inputParser.forceFlushEscape();
if (escEvent != null) {
// Add to keyboard event stream
_keyboardEventController.add(escEvent);

// Route the event through the component tree
_routeKeyboardEvent(escEvent);

// Schedule a frame if needed
if (buildOwner.hasDirtyElements) {
scheduleFrame();
}
}
}

/// Process bytes in shell mode to extract terminal size OSC sequences
/// Returns filtered bytes with OSC sequences removed
List<int> _processOscSequences(List<int> bytes) {
Expand Down Expand Up @@ -244,8 +284,10 @@ class TerminalBinding extends NoctermBinding

if (foundTerminator && end < bytes.length) {
// Extract OSC content
final oscContent =
utf8.decode(bytes.sublist(i + 2, end), allowMalformed: true);
final oscContent = utf8.decode(
bytes.sublist(i + 2, end),
allowMalformed: true,
);

// Handle OSC sequence based on command number
_handleOscSequence(oscContent);
Expand Down Expand Up @@ -353,7 +395,8 @@ class TerminalBinding extends NoctermBinding
if (event is KeyboardInputEvent) {
final keyEvent = event.event;
// Check if this is a simple printable character (no modifiers except shift)
final isPrintable = keyEvent.character != null &&
final isPrintable =
keyEvent.character != null &&
keyEvent.character!.isNotEmpty &&
!keyEvent.isControlPressed &&
!keyEvent.isAltPressed &&
Expand Down Expand Up @@ -431,6 +474,10 @@ class TerminalBinding extends NoctermBinding
if (_shouldExit) return;
_shouldExit = true;

// Cancel escape timer if pending
_escapeTimer?.cancel();
_escapeTimer = null;

// Cancel all subscriptions immediately
_inputSubscription?.cancel();
_resizeSubscription?.cancel();
Expand Down Expand Up @@ -502,8 +549,12 @@ class TerminalBinding extends NoctermBinding
// Find the render object at the mouse position
final renderObject = _findRenderObjectInTree(rootElement!);
if (renderObject != null) {
_dispatchMouseWheelAtPosition(rootElement!, event,
Offset(event.x.toDouble(), event.y.toDouble()), Offset.zero);
_dispatchMouseWheelAtPosition(
rootElement!,
event,
Offset(event.x.toDouble(), event.y.toDouble()),
Offset.zero,
);
}
}

Expand Down Expand Up @@ -538,27 +589,28 @@ class TerminalBinding extends NoctermBinding
bool _dispatchKeyToElement(Element element, KeyboardEvent event) {
// Check if this element is a BlockFocus that's blocking
if (element is BlockFocusElement && element.isBlocking) {
// Block all keyboard events from reaching children
return false; // Event is "handled" (blocked)
}

// TODO: This is a hack to handle RenderTheater specially for Navigator
// Should be properly integrated into the render object hierarchy
// First, try to dispatch to children (depth-first)
bool handled = false;

// Special handling for RenderTheater (Navigator's Overlay):
// Only dispatch to the topmost route (last child) for keyboard events
if (element.renderObject is RenderTheater) {
final multiChildRenderObject = element as MultiChildRenderObjectElement;
if (multiChildRenderObject.children.isNotEmpty) {
final child = multiChildRenderObject.children.last;
return _dispatchKeyToElement(child, event);
}
}

// First, try to dispatch to children (depth-first)
bool handled = false;
element.visitChildren((child) {
if (!handled) {
handled = _dispatchKeyToElement(child, event);
}
});
} else {
// Normal case: visit all children
element.visitChildren((child) {
if (!handled) {
handled = _dispatchKeyToElement(child, event);
}
});
}

// If no child handled it, and this element can handle keys, try it
if (!handled && element is FocusableElement) {
Expand All @@ -569,16 +621,24 @@ class TerminalBinding extends NoctermBinding
}

/// Dispatch a mouse wheel event to scrollable RenderObjects at a specific position
bool _dispatchMouseWheelAtPosition(Element element, MouseEvent event,
Offset mousePos, Offset currentOffset) {
bool _dispatchMouseWheelAtPosition(
Element element,
MouseEvent event,
Offset mousePos,
Offset currentOffset,
) {
// TODO: This is a hack to handle RenderTheater specially for Navigator
// Should be properly integrated into the render object hierarchy
if (element.renderObject is RenderTheater) {
final multiChildRenderObject = element as MultiChildRenderObjectElement;
if (multiChildRenderObject.children.isNotEmpty) {
final child = multiChildRenderObject.children.last;
return _dispatchMouseWheelAtPosition(
child, event, mousePos, currentOffset);
child,
event,
mousePos,
currentOffset,
);
}
}

Expand Down Expand Up @@ -632,7 +692,11 @@ class TerminalBinding extends NoctermBinding
for (final child in children.reversed) {
if (!handled) {
handled = _dispatchMouseWheelAtPosition(
child, event, mousePos, childrenOffset);
child,
event,
mousePos,
childrenOffset,
);
}
}

Expand Down Expand Up @@ -685,6 +749,11 @@ class TerminalBinding extends NoctermBinding
if (_shouldExit) return;

_shouldExit = true;

// Cancel escape timer if pending
_escapeTimer?.cancel();
_escapeTimer = null;

_inputSubscription?.cancel();
_resizeSubscription?.cancel();

Expand Down Expand Up @@ -835,7 +904,8 @@ class TerminalBinding extends NoctermBinding
terminal.moveCursor(x, y);

// Handle style
final hasStyle = cell.style.color != null ||
final hasStyle =
cell.style.color != null ||
cell.style.backgroundColor != null ||
cell.style.fontWeight == FontWeight.bold ||
cell.style.fontWeight == FontWeight.dim ||
Expand Down Expand Up @@ -880,7 +950,8 @@ class TerminalBinding extends NoctermBinding
final cell = buffer.getCell(x, y);

// Handle style
final hasStyle = cell.style.color != null ||
final hasStyle =
cell.style.color != null ||
cell.style.backgroundColor != null ||
cell.style.fontWeight == FontWeight.bold ||
cell.style.fontWeight == FontWeight.dim ||
Expand Down Expand Up @@ -928,8 +999,12 @@ class TerminalBinding extends NoctermBinding
// Get current terminal size (may have been updated by resize event)
final size = terminal.size;
final buffer = buf.Buffer(size.width.toInt(), size.height.toInt());
final screenRect =
Rect.fromLTWH(0, 0, size.width.toDouble(), size.height.toDouble());
final screenRect = Rect.fromLTWH(
0,
0,
size.width.toDouble(),
size.height.toDouble(),
);

// Find render object in tree
RenderObject? findRenderObject(Element element) {
Expand All @@ -952,8 +1027,11 @@ class TerminalBinding extends NoctermBinding
}

// Layout phase
renderObject.layout(BoxConstraints.tight(
Size(size.width.toDouble(), size.height.toDouble())));
renderObject.layout(
BoxConstraints.tight(
Size(size.width.toDouble(), size.height.toDouble()),
),
);

// Flush layout pipeline
pipelineOwner.flushLayout();
Expand Down
4 changes: 1 addition & 3 deletions lib/src/components/focusable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ class FocusableElement extends StatelessElement {
if (!component.focused) {
return false;
}

final handled = component.onKeyEvent(event);
return handled;
return component.onKeyEvent(event);
}
}
15 changes: 12 additions & 3 deletions lib/src/components/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,12 @@ class _TextFieldState extends State<TextField> {
component.onEditingComplete?.call();
component.onSubmitted?.call(_controller.text);
return true;
} else if (key == LogicalKey.backspace && event.isControlPressed) {
_deleteWordBackward();
return true;
} else if (key == LogicalKey.backspace && event.isAltPressed) {
_deleteWordBackward();
return true;
} else if (key == LogicalKey.backspace) {
_handleBackspace();
return true;
Expand All @@ -402,6 +408,12 @@ class _TextFieldState extends State<TextField> {
} else if (key == LogicalKey.arrowRight && event.isControlPressed) {
_moveCursorByWord(1, false);
return true;
} else if (key == LogicalKey.arrowLeft && event.isAltPressed) {
_moveCursorByWord(-1, false);
return true;
} else if (key == LogicalKey.arrowRight && event.isAltPressed) {
_moveCursorByWord(1, false);
return true;
} else if (key == LogicalKey.arrowUp &&
event.isShiftPressed &&
component.maxLines != 1) {
Expand Down Expand Up @@ -442,9 +454,6 @@ class _TextFieldState extends State<TextField> {
} else if (event.matches(LogicalKey.keyV, ctrl: true)) {
_paste();
return true;
} else if (key == LogicalKey.backspace && event.isControlPressed) {
_deleteWordBackward();
return true;
} else if (key == LogicalKey.delete && event.isControlPressed) {
_deleteWordForward();
return true;
Expand Down
51 changes: 49 additions & 2 deletions lib/src/keyboard/input_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,9 @@ class InputParser {
}

(KeyboardEvent, int)? _parseEscapeSequence() {
if (_buffer.length == 1) {
// Just ESC key pressed
final len = _buffer.length;

if (len == 1) {
return (
KeyboardEvent(
logicalKey: LogicalKey.escape,
Expand Down Expand Up @@ -290,6 +291,17 @@ class InputParser {
);
}

// Alt+Backspace (ESC followed by DEL/0x7F)
if (second == 0x7F) {
return (
KeyboardEvent(
logicalKey: LogicalKey.backspace,
modifiers: const ModifierKeys(alt: true),
),
2
);
}

// If it's not a complete Alt sequence, might be start of longer sequence
if (second != 0x5B && second != 0x4F) {
// Not a CSI or SS3 sequence, treat as ESC + char
Expand Down Expand Up @@ -766,4 +778,39 @@ class InputParser {
void clear() {
_buffer.clear();
}

/// Check if buffer contains an incomplete escape sequence.
/// Returns true if buffer starts with ESC (0x1B) but parseNext would return null.
bool hasIncompleteEscapeSequence() {
if (_buffer.isEmpty || _buffer[0] != 0x1B) {
return false;
}
// If buffer has only ESC, it's not incomplete - it would return ESC event
if (_buffer.length == 1) {
return false;
}
// Check if current buffer would produce an event
// Save buffer state and try parsing
final savedBuffer = List<int>.from(_buffer);
final result = _parseBufferWithLength();
// Restore buffer (parsing may have modified it in some edge cases)
_buffer.clear();
_buffer.addAll(savedBuffer);
// If no result, we have an incomplete sequence
return result == null;
}

/// Force flush the buffer as an ESC key event.
/// Clears the buffer and returns an ESC KeyboardEvent.
/// Returns null if buffer doesn't start with ESC.
KeyboardEvent? forceFlushEscape() {
if (_buffer.isEmpty || _buffer[0] != 0x1B) {
return null;
}
_buffer.clear();
return KeyboardEvent(
logicalKey: LogicalKey.escape,
modifiers: const ModifierKeys(),
);
}
}
6 changes: 2 additions & 4 deletions lib/src/navigation/pop_behavior.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@ class PopBehavior {

/// Check if the given key should trigger a pop
bool shouldPop(LogicalKey key) {
if (key == LogicalKey.escape && escapeEnabled) return true; // ESC key
if (key == LogicalKey.backspace && backspaceEnabled) {
return true; // Backspace
}
if (key == LogicalKey.escape && escapeEnabled) return true;
if (key == LogicalKey.backspace && backspaceEnabled) return true;
if (customPopKey != null && key.debugName == customPopKey) return true;
return false;
}
Expand Down