From 6c9449046fe1c3d7b9eb4c3e9d22aa3919a55a83 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 7 Aug 2026 20:36:54 +0300 Subject: [PATCH 01/22] chore(libghostty): sync ghostty source and terminal bindings --- packages/libghostty/ghostty.version | 2 +- .../lib/src/bindings/interface.dart | 5 + .../lib/src/bindings/native/native.dart | 81 +- .../lib/src/bindings/types/result.dart | 20 +- .../lib/src/bindings/wasm/layouts.dart | 10 + .../lib/src/bindings/wasm/wasm.dart | 102 ++- .../libghostty/lib/src/ffi/libghostty.g.dart | 758 ++++++++++++++++-- .../lib/src/ffi/libghostty_enums.g.dart | 207 ++++- .../lib/src/ffi/libghostty_wasm.g.dart | 380 ++++++++- .../test/bindings/bindings_native_test.dart | 48 ++ .../test/wasm/bindings_wasm_test.dart | 48 ++ packages/libghostty/tool/ffigen.dart | 4 +- 12 files changed, 1555 insertions(+), 110 deletions(-) diff --git a/packages/libghostty/ghostty.version b/packages/libghostty/ghostty.version index fd6d9877..95482e2e 100644 --- a/packages/libghostty/ghostty.version +++ b/packages/libghostty/ghostty.version @@ -1 +1 @@ -4d605bf0d819df901a0332bbb320dc849fdd82e4 +34282fc7b3ba3e7f42281db73647a83768710f89 diff --git a/packages/libghostty/lib/src/bindings/interface.dart b/packages/libghostty/lib/src/bindings/interface.dart index 905e14f7..5c39c11a 100644 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ b/packages/libghostty/lib/src/bindings/interface.dart @@ -139,6 +139,8 @@ abstract interface class GhosttyBindings { CResult terminalGetScrollbar(int handle); CResult terminalModeGet(int handle, int mode); Result terminalModeSet(int handle, int mode, {required bool value}); + Result terminalModeSetDefault(int handle, int mode, {required bool value}); + Result terminalSetTitleReport(int handle, {required bool enabled}); CResult terminalGetTitle(int handle); CResult terminalGetPwd(int handle); CResult terminalGetTotalRows(int handle); @@ -150,6 +152,9 @@ abstract interface class GhosttyBindings { CResult terminalGetGeometry(int handle); CResult terminalGetViewportActive(int handle); CResult terminalGetVtProcessingError(int handle); + CResult terminalContinuationGet(int handle); + CResult terminalGetContinuationMaxBytes(int handle); + Result terminalSetContinuationMaxBytes(int handle, int? bytes); Result terminalSetTitle(int handle, String? title); Result terminalSetPwd(int handle, String? pwd); Result terminalSetDefaultCursorShape(int handle, CursorShape? shape); diff --git a/packages/libghostty/lib/src/bindings/native/native.dart b/packages/libghostty/lib/src/bindings/native/native.dart index 19f4101f..ac0a0873 100644 --- a/packages/libghostty/lib/src/bindings/native/native.dart +++ b/packages/libghostty/lib/src/bindings/native/native.dart @@ -6,7 +6,13 @@ import 'package:ffi/ffi.dart'; import '../../ffi/libghostty.g.dart' as native - show ClipboardWrite, MouseEncoderSize, SgrAttribute, String, Style; + show + ClipboardWrite, + MouseEncoderSize, + SgrAttribute, + String, + Style, + TerminalModeConfig; import '../../ffi/libghostty.g.dart' hide ClipboardContent, @@ -172,6 +178,7 @@ class NativeBindings implements GhosttyBindings { final _outU64 = calloc(); final _outI32 = calloc(); final _outBool = calloc(); + final _outModeConfig = calloc(); final _outStyle = calloc(); final _outScrollbar = calloc(); final _outColors = calloc(); @@ -1132,17 +1139,42 @@ class NativeBindings implements GhosttyBindings { @override CResult terminalModeGet(int handle, int mode) { - final result = ghostty_terminal_mode_get( + _outModeConfig.ref.mode = mode; + final result = ghostty_terminal_get( Pointer.fromAddress(handle), - mode, - _outBool, + .mode, + _outModeConfig.cast(), ); - return (result, _outBool.value); + return (result, _outModeConfig.ref.value); } @override Result terminalModeSet(int handle, int mode, {required bool value}) { - return ghostty_terminal_mode_set(Pointer.fromAddress(handle), mode, value); + _outModeConfig.ref + ..mode = mode + ..value = value; + return ghostty_terminal_set( + Pointer.fromAddress(handle), + .mode, + _outModeConfig.cast(), + ); + } + + @override + Result terminalModeSetDefault(int handle, int mode, {required bool value}) { + _outModeConfig.ref + ..mode = mode + ..value = value; + return ghostty_terminal_set( + Pointer.fromAddress(handle), + .modeDefault, + _outModeConfig.cast(), + ); + } + + @override + Result terminalSetTitleReport(int handle, {required bool enabled}) { + return _terminalSetBool(handle, .titleReport, enabled); } @override @@ -1211,6 +1243,43 @@ class NativeBindings implements GhosttyBindings { return _terminalGetBool(handle, .vtProcessingError); } + @override + CResult terminalContinuationGet(int handle) { + return using((arena) { + final outWritten = arena(); + var result = ghostty_terminal_continuation_buf( + Pointer.fromAddress(handle), + nullptr, + 0, + outWritten, + ); + if (result != .outOfSpace) return (result, Uint8List(0)); + + final capacity = outWritten.value; + if (capacity == 0) return (Result.success, Uint8List(0)); + final buffer = arena(capacity); + result = ghostty_terminal_continuation_buf( + Pointer.fromAddress(handle), + buffer, + capacity, + outWritten, + ); + if (result != .success) return (result, Uint8List(0)); + + return (result, Uint8List.fromList(buffer.asTypedList(outWritten.value))); + }); + } + + @override + CResult terminalGetContinuationMaxBytes(int handle) { + return _terminalGetSize(handle, .continuationMaxBytes); + } + + @override + Result terminalSetContinuationMaxBytes(int handle, int? bytes) { + return _terminalSetSize(handle, .continuationMaxBytes, bytes); + } + @override Result terminalSetTitle(int handle, String? title) { return _terminalSetString(handle, .title, title); diff --git a/packages/libghostty/lib/src/bindings/types/result.dart b/packages/libghostty/lib/src/bindings/types/result.dart index e4e95223..e8b5fb52 100644 --- a/packages/libghostty/lib/src/bindings/types/result.dart +++ b/packages/libghostty/lib/src/bindings/types/result.dart @@ -12,7 +12,9 @@ T check(CResult result) { /// Throws [OutOfMemoryException] for [Result.outOfMemory], /// [InvalidValueException] for [Result.invalidValue], /// [OutOfSpaceException] for [Result.outOfSpace], and -/// [NoValueException] for [Result.noValue]. +/// [NoValueException] for [Result.noValue], [IoException] for +/// [Result.ioError], and [LimitExceededException] for +/// [Result.limitExceeded]. void checkCode(Result code) { switch (code) { case Result.outOfMemory: @@ -23,6 +25,10 @@ void checkCode(Result code) { throw const OutOfSpaceException(); case Result.noValue: throw const NoValueException(); + case Result.ioError: + throw const IoException(); + case Result.limitExceeded: + throw const LimitExceededException(); case Result.success: break; } @@ -71,3 +77,15 @@ class OutOfMemoryException extends LibGhosttyException { class OutOfSpaceException extends LibGhosttyException { const OutOfSpaceException([super.message = 'Output buffer too small.']); } + +/// An external I/O operation required by the native API failed. +class IoException extends LibGhosttyException { + const IoException([super.message = 'An external I/O operation failed.']); +} + +/// A configured output limit prevented the native operation from completing. +class LimitExceededException extends LibGhosttyException { + const LimitExceededException([ + super.message = 'An operation exceeded a configured limit.', + ]); +} diff --git a/packages/libghostty/lib/src/bindings/wasm/layouts.dart b/packages/libghostty/lib/src/bindings/wasm/layouts.dart index af9dc25c..3d359f5c 100644 --- a/packages/libghostty/lib/src/bindings/wasm/layouts.dart +++ b/packages/libghostty/lib/src/bindings/wasm/layouts.dart @@ -31,6 +31,11 @@ class Layouts { late final int terminalProgressReportState; late final int terminalProgressReportProgress; + // GhosttyTerminalModeConfig + late final int terminalModeConfigSize; + late final int terminalModeConfigMode; + late final int terminalModeConfigValue; + // GhosttyColorRgb late final int colorRgbSize; late final int colorRgbG; @@ -258,6 +263,11 @@ class Layouts { terminalProgressReportState = struct['state']; terminalProgressReportProgress = struct['progress']; + struct = _Struct(types, 'GhosttyTerminalModeConfig'); + terminalModeConfigSize = struct.size; + terminalModeConfigMode = struct['mode']; + terminalModeConfigValue = struct['value']; + struct = _Struct(types, 'GhosttyColorRgb'); colorRgbSize = struct.size; colorRgbG = struct['g']; diff --git a/packages/libghostty/lib/src/bindings/wasm/wasm.dart b/packages/libghostty/lib/src/bindings/wasm/wasm.dart index 5cd10e63..811a0f48 100644 --- a/packages/libghostty/lib/src/bindings/wasm/wasm.dart +++ b/packages/libghostty/lib/src/bindings/wasm/wasm.dart @@ -1297,18 +1297,64 @@ class WasmBindings implements GhosttyBindings { @override CResult terminalModeGet(int handle, int mode) { - final outPtr = _exports.ghostty_wasm_alloc_u8(); - final result = _exports.ghostty_terminal_mode_get(handle, mode, outPtr); - final value = _mem.readU8(outPtr) != 0; - _exports.ghostty_wasm_free_u8(outPtr); + final modePtr = _exports.ghostty_wasm_alloc_u8_array( + _layout.terminalModeConfigSize, + ); + _mem.writeU16(modePtr + _layout.terminalModeConfigMode, mode); + final result = _exports.ghostty_terminal_get( + handle, + TerminalData.mode.value, + modePtr, + ); + final value = _mem.readU8(modePtr + _layout.terminalModeConfigValue) != 0; + _exports.ghostty_wasm_free_u8_array( + modePtr, + _layout.terminalModeConfigSize, + ); return (.fromValue(result), value); } @override Result terminalModeSet(int handle, int mode, {required bool value}) { - return .fromValue( - _exports.ghostty_terminal_mode_set(handle, mode, value ? 1 : 0), + final modePtr = _exports.ghostty_wasm_alloc_u8_array( + _layout.terminalModeConfigSize, + ); + _mem.writeU16(modePtr + _layout.terminalModeConfigMode, mode); + _mem.writeU8(modePtr + _layout.terminalModeConfigValue, value ? 1 : 0); + final result = _exports.ghostty_terminal_set( + handle, + TerminalOption.mode.value, + modePtr, ); + _exports.ghostty_wasm_free_u8_array( + modePtr, + _layout.terminalModeConfigSize, + ); + return .fromValue(result); + } + + @override + Result terminalModeSetDefault(int handle, int mode, {required bool value}) { + final modePtr = _exports.ghostty_wasm_alloc_u8_array( + _layout.terminalModeConfigSize, + ); + _mem.writeU16(modePtr + _layout.terminalModeConfigMode, mode); + _mem.writeU8(modePtr + _layout.terminalModeConfigValue, value ? 1 : 0); + final result = _exports.ghostty_terminal_set( + handle, + TerminalOption.modeDefault.value, + modePtr, + ); + _exports.ghostty_wasm_free_u8_array( + modePtr, + _layout.terminalModeConfigSize, + ); + return .fromValue(result); + } + + @override + Result terminalSetTitleReport(int handle, {required bool enabled}) { + return _terminalSetBool(handle, TerminalOption.titleReport, enabled); } @override @@ -1367,6 +1413,50 @@ class WasmBindings implements GhosttyBindings { return _terminalGetBool(handle, .vtProcessingError); } + @override + CResult terminalContinuationGet(int handle) { + final outWritten = _allocateSize(); + try { + var result = Result.fromValue( + _exports.ghostty_terminal_continuation_buf(handle, 0, 0, outWritten), + ); + if (result != .outOfSpace) return (result, Uint8List(0)); + + final capacity = _mem.readU32(outWritten); + if (capacity == 0) return (Result.success, Uint8List(0)); + final buffer = _allocateBytes(capacity); + try { + result = Result.fromValue( + _exports.ghostty_terminal_continuation_buf( + handle, + buffer, + capacity, + outWritten, + ), + ); + final written = _mem.readU32(outWritten); + final continuation = result == .success + ? Uint8List.fromList(_mem.readBytes(buffer, written)) + : Uint8List(0); + return (result, continuation); + } finally { + _exports.ghostty_wasm_free_u8_array(buffer, capacity); + } + } finally { + _exports.ghostty_wasm_free_usize(outWritten); + } + } + + @override + CResult terminalGetContinuationMaxBytes(int handle) { + return _terminalGetU32(handle, .continuationMaxBytes); + } + + @override + Result terminalSetContinuationMaxBytes(int handle, int? bytes) { + return _terminalSetSize(handle, .continuationMaxBytes, bytes); + } + @override Result terminalSetTitle(int handle, String? title) { return _terminalSetString(handle, TerminalOption.title, title); diff --git a/packages/libghostty/lib/src/ffi/libghostty.g.dart b/packages/libghostty/lib/src/ffi/libghostty.g.dart index a3a022ff..ce77c0c1 100644 --- a/packages/libghostty/lib/src/ffi/libghostty.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty.g.dart @@ -3671,6 +3671,442 @@ Result ghostty_size_report_encode( ); } +/// Decode and validate one complete snapshot. +/// +/// This is the one-shot form of READY followed by all history pages through +/// FINISH. It may only be called before decoding starts. Bytes following FINISH +/// are left unread. On success terminal receives a caller-owned terminal with +/// its persistent VT stream restored. Continuation tracking on the returned +/// terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES +/// returns zero. terminal is set to NULL on every error. +/// A decoding, I/O, or allocation error after input consumption begins poisons +/// the decoder, after which it must be freed. An invalid argument or +/// lifecycle error detected before the operation consumes input does not +/// poison it. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @param[out] terminal Pointer to receive the terminal (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native)>( + symbol: 'ghostty_snapshot_decoder_decode', + isLeaf: true, +) +external int _ghostty_snapshot_decoder_decode( + SnapshotDecoder decoder, + ffi.Pointer terminal, +); + +Result ghostty_snapshot_decoder_decode( + SnapshotDecoder decoder, + ffi.Pointer terminal, +) { + return Result.fromValue(_ghostty_snapshot_decoder_decode(decoder, terminal)); +} + +/// Free a snapshot decoder. +/// +/// This does not release the caller's ownership of a terminal returned by +/// ready or decode. Abandoning an incremental decode leaves that terminal +/// usable with whatever history had already been restored. +/// +/// @param decoder Decoder to free (may be NULL) +/// +/// @ingroup snapshot +@ffi.Native(isLeaf: true) +external void ghostty_snapshot_decoder_free(SnapshotDecoder decoder); + +/// Get typed data from a snapshot decoder. +/// +/// The output pointer must have the type documented by data. A phase-dependent +/// value that is not currently available returns GHOSTTY_NO_VALUE. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @param data Data kind to query +/// @param[out] out Pointer to receive the value (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data +/// is unavailable, or another error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function(SnapshotDecoder, ffi.UnsignedInt, ffi.Pointer) +>(symbol: 'ghostty_snapshot_decoder_get', isLeaf: true) +external int _ghostty_snapshot_decoder_get( + SnapshotDecoder decoder, + int data, + ffi.Pointer out, +); + +Result ghostty_snapshot_decoder_get( + SnapshotDecoder decoder, + SnapshotDecoderData data, + ffi.Pointer out, +) { + return Result.fromValue( + _ghostty_snapshot_decoder_get(decoder, data.value, out), + ); +} + +/// Get multiple snapshot decoder data fields in a single call. +/// +/// Each keys element selects a data kind and the corresponding values element +/// points to storage of the documented output type. Processing stops at the +/// first error. On success out_written is set to count; on error it is set to +/// the number of values written before the failing key. Invalid array arguments +/// report zero values written. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @param count Number of key/value pairs +/// @param keys Array of data kinds to query +/// @param values Array of output pointers corresponding to keys +/// @param[out] out_written Number of successfully written values (may be NULL) +/// @return GHOSTTY_SUCCESS if every query succeeds, or the first error +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function( + SnapshotDecoder, + ffi.Size, + ffi.Pointer, + ffi.Pointer>, + ffi.Pointer, + ) +>(symbol: 'ghostty_snapshot_decoder_get_multi', isLeaf: true) +external int _ghostty_snapshot_decoder_get_multi( + SnapshotDecoder decoder, + int count, + ffi.Pointer keys, + ffi.Pointer> values, + ffi.Pointer out_written, +); + +Result ghostty_snapshot_decoder_get_multi( + SnapshotDecoder decoder, + int count, + ffi.Pointer keys, + ffi.Pointer> values, + ffi.Pointer out_written, +) { + return Result.fromValue( + _ghostty_snapshot_decoder_get_multi( + decoder, + count, + keys, + values, + out_written, + ), + ); +} + +/// Create a snapshot decoder that reads from a caller-provided reader. +/// +/// The decoder stores a copy of reader. Its read callback must not be NULL, and +/// both the callback and its caller-owned context must remain valid until +/// FINISH is reached or the decoder is freed. Reads are synchronous and occur +/// only during ready, next, or decode calls. A zero-byte successful read is +/// permanent end-of-file, not temporary starvation; nonblocking sources must +/// wait outside the decoder or block in their callback. The read callback must +/// not call APIs, including ghostty_snapshot_decoder_free(), on the decoder +/// that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with +/// zero bytes before a required marker reports truncated snapshot data as +/// GHOSTTY_INVALID_VALUE. +/// +/// @param allocator Allocator for decoder and decoded terminal state, or NULL +/// for the default allocator +/// @param decoder Pointer to receive the decoder handle (must not be NULL) +/// @param reader Snapshot source reader +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function(ffi.Pointer, ffi.Pointer, Reader) +>(symbol: 'ghostty_snapshot_decoder_new', isLeaf: true) +external int _ghostty_snapshot_decoder_new( + ffi.Pointer allocator, + ffi.Pointer decoder, + Reader reader, +); + +Result ghostty_snapshot_decoder_new( + ffi.Pointer allocator, + ffi.Pointer decoder, + Reader reader, +) { + return Result.fromValue( + _ghostty_snapshot_decoder_new(allocator, decoder, reader), + ); +} + +/// Create a snapshot decoder over a borrowed byte buffer. +/// +/// The bytes are not copied. ptr must remain valid and immutable until FINISH +/// is reached or the decoder is freed. Bytes after FINISH are not consumed; +/// query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them. +/// +/// @param allocator Allocator for decoder and decoded terminal state, or NULL +/// for the default allocator +/// @param decoder Pointer to receive the decoder handle (must not be NULL) +/// @param ptr Snapshot source bytes +/// @param len Number of source bytes +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Size, + ) +>(symbol: 'ghostty_snapshot_decoder_new_buf', isLeaf: true) +external int _ghostty_snapshot_decoder_new_buf( + ffi.Pointer allocator, + ffi.Pointer decoder, + ffi.Pointer ptr, + int len, +); + +Result ghostty_snapshot_decoder_new_buf( + ffi.Pointer allocator, + ffi.Pointer decoder, + ffi.Pointer ptr, + int len, +) { + return Result.fromValue( + _ghostty_snapshot_decoder_new_buf(allocator, decoder, ptr, len), + ); +} + +/// Decode one history page into the terminal returned by READY. +/// +/// Each GHOSTTY_SUCCESS consumes and validates one PAGE record. Query the +/// GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again. +/// GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH +/// also return GHOSTTY_NO_VALUE. +/// +/// The terminal may be rendered, resized, and fed live PTY input between calls. +/// If a history page can no longer be applied safely, it is still consumed and +/// validated and progress reports zero rows. The decoder applies history +/// to the caller-owned terminal produced by its READY operation. +/// +/// A decoding error invalidates the decoder's source position. The terminal +/// remains caller-owned and usable with its already-restored history, but only +/// ghostty_snapshot_decoder_free() may subsequently be called on the decoder. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an +/// error code on failure +/// +/// @ingroup snapshot +@ffi.Native( + symbol: 'ghostty_snapshot_decoder_next', + isLeaf: true, +) +external int _ghostty_snapshot_decoder_next(SnapshotDecoder decoder); + +Result ghostty_snapshot_decoder_next(SnapshotDecoder decoder) { + return Result.fromValue(_ghostty_snapshot_decoder_next(decoder)); +} + +/// Decode and validate the renderable snapshot prefix through READY. +/// +/// On success, terminal receives a caller-owned terminal with its persistent +/// VT stream already restored from the snapshot continuation. The terminal is +/// immediately usable for rendering and live input. Older scrollback remains +/// to be restored with ghostty_snapshot_decoder_next(). +/// +/// The restored parser state may be unfinished, but terminal continuation +/// tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns +/// zero. The decoder's continuation option is an input limit, not terminal +/// runtime policy. +/// +/// The caller must keep the returned terminal alive until FINISH validates or +/// the decoder is freed. The decoder borrows this terminal handle while it +/// restores history; ghostty_snapshot_decoder_next() uses it automatically. +/// +/// This operation may only be called once and only before decoding starts. +/// terminal is set to NULL on every error. A decoding, I/O, or allocation +/// error after input consumption begins poisons the decoder, after which it +/// must be freed. An invalid argument or lifecycle error detected before the +/// operation consumes input does not poison it. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @param[out] terminal Pointer to receive the terminal (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native)>( + symbol: 'ghostty_snapshot_decoder_ready', + isLeaf: true, +) +external int _ghostty_snapshot_decoder_ready( + SnapshotDecoder decoder, + ffi.Pointer terminal, +); + +Result ghostty_snapshot_decoder_ready( + SnapshotDecoder decoder, + ffi.Pointer terminal, +) { + return Result.fromValue(_ghostty_snapshot_decoder_ready(decoder, terminal)); +} + +/// Set a snapshot decoder option. +/// +/// The value pointer must have the type documented by option. Options may only +/// be changed before decoding starts. +/// +/// @param decoder Decoder handle (must not be NULL) +/// @param option Option to change +/// @param value Pointer to the option value (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has +/// started or an argument is invalid, or another error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function(SnapshotDecoder, ffi.UnsignedInt, ffi.Pointer) +>(symbol: 'ghostty_snapshot_decoder_set', isLeaf: true) +external int _ghostty_snapshot_decoder_set( + SnapshotDecoder decoder, + int option, + ffi.Pointer value, +); + +Result ghostty_snapshot_decoder_set( + SnapshotDecoder decoder, + SnapshotDecoderOption option, + ffi.Pointer value, +) { + return Result.fromValue( + _ghostty_snapshot_decoder_set(decoder, option.value, value), + ); +} + +/// Encode a complete terminal snapshot to a writer. +/// +/// The terminal's persistent VT stream supplies the continuation bytes needed +/// to reconstruct unfinished parser state. The caller must prevent concurrent +/// writes or other terminal mutation for the duration of this call. The writer +/// callback must not call terminal APIs with the same terminal handle. +/// A terminal can be encoded with tracking disabled when its VT parser and +/// UTF-8 decoder are both at ground. If either is unfinished, tracking must +/// have been enabled before the input that produced that state was written; +/// otherwise this returns GHOSTTY_INVALID_VALUE. +/// +/// Encoding begins at the writer's current position. If an error occurs, the +/// writer may contain a partial snapshot without a valid FINISH marker. +/// Calls to the writer are synchronous; this function does not flush or make +/// the caller's destination durable. +/// +/// @param terminal Terminal to encode (must not be NULL) +/// @param writer Destination writer whose write callback must not be NULL +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects +/// output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or +/// another error code on failure +/// +/// @ingroup snapshot +@ffi.Native( + symbol: 'ghostty_snapshot_encode', + isLeaf: true, +) +external int _ghostty_snapshot_encode(Terminal terminal, Writer writer); + +Result ghostty_snapshot_encode(Terminal terminal, Writer writer) { + return Result.fromValue(_ghostty_snapshot_encode(terminal, writer)); +} + +/// Encode a complete terminal snapshot to an allocated buffer. +/// +/// The returned buffer is allocated with allocator, or the default allocator +/// when allocator is NULL. The caller must release it with ghostty_free(), +/// passing the same allocator used here. +/// +/// A terminal can be encoded with tracking disabled when its VT parser and +/// UTF-8 decoder are both at ground. If either is unfinished, tracking must +/// have been enabled before the input that produced that state was written; +/// otherwise this returns GHOSTTY_INVALID_VALUE. +/// +/// @param terminal Terminal to encode (must not be NULL) +/// @param allocator Allocator for the output, or NULL for the default allocator +/// @param[out] out_ptr Allocated snapshot bytes (must not be NULL) +/// @param[out] out_len Number of allocated snapshot bytes (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function( + Terminal, + ffi.Pointer, + ffi.Pointer>, + ffi.Pointer, + ) +>(symbol: 'ghostty_snapshot_encode_alloc', isLeaf: true) +external int _ghostty_snapshot_encode_alloc( + Terminal terminal, + ffi.Pointer allocator, + ffi.Pointer> out_ptr, + ffi.Pointer out_len, +); + +Result ghostty_snapshot_encode_alloc( + Terminal terminal, + ffi.Pointer allocator, + ffi.Pointer> out_ptr, + ffi.Pointer out_len, +) { + return Result.fromValue( + _ghostty_snapshot_encode_alloc(terminal, allocator, out_ptr, out_len), + ); +} + +/// Encode a complete terminal snapshot to a caller-provided buffer. +/// +/// Pass NULL for buf with buf_len zero to query the required size. If the +/// buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the +/// required capacity in out_written. A non-NULL undersized buffer may contain +/// a partial snapshot prefix. On success, out_written receives the number of +/// bytes encoded. +/// +/// A terminal can be encoded with tracking disabled when its VT parser and +/// UTF-8 decoder are both at ground. If either is unfinished, tracking must +/// have been enabled before the input that produced that state was written; +/// otherwise this returns GHOSTTY_INVALID_VALUE. +/// +/// @param terminal Terminal to encode (must not be NULL) +/// @param buf Destination buffer, or NULL when buf_len is zero +/// @param buf_len Destination buffer capacity in bytes +/// @param[out] out_written Bytes written, or required capacity on +/// GHOSTTY_OUT_OF_SPACE (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, or an error code on failure +/// +/// @ingroup snapshot +@ffi.Native< + ffi.Int Function( + Terminal, + ffi.Pointer, + ffi.Size, + ffi.Pointer, + ) +>(symbol: 'ghostty_snapshot_encode_buf', isLeaf: true) +external int _ghostty_snapshot_encode_buf( + Terminal terminal, + ffi.Pointer buf, + int buf_len, + ffi.Pointer out_written, +); + +Result ghostty_snapshot_encode_buf( + Terminal terminal, + ffi.Pointer buf, + int buf_len, + ffi.Pointer out_written, +) { + return Result.fromValue( + _ghostty_snapshot_encode_buf(terminal, buf, buf_len, out_written), + ); +} + /// Get the default style. /// /// Initializes the style to the default values (no colors, no flags). @@ -3836,6 +4272,142 @@ Result ghostty_terminal_compression_activity( ); } +/// Return an allocated copy of the terminal's replay-safe VT continuation. +/// +/// The returned bytes are allocated with allocator, or the default allocator +/// when allocator is NULL. The caller must release them with ghostty_free(), +/// passing the same allocator and returned length. An empty continuation is a +/// successful zero-length allocation. +/// Continuation tracking must have been enabled by setting +/// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the +/// input that produced the continuation was written. +/// +/// The caller must serialize this operation with all other access to the same +/// terminal. +/// +/// @param terminal Terminal to read from (must not be NULL) +/// @param allocator Allocator for the output, or NULL for the default allocator +/// @param[out] out_ptr Allocated continuation bytes (must not be NULL) +/// @param[out] out_len Number of continuation bytes (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation +/// failure, or GHOSTTY_INVALID_VALUE if an argument is invalid, +/// tracking is disabled, or the current continuation is unavailable +/// +/// @ingroup terminal +@ffi.Native< + ffi.Int Function( + Terminal, + ffi.Pointer, + ffi.Pointer>, + ffi.Pointer, + ) +>(symbol: 'ghostty_terminal_continuation_alloc', isLeaf: true) +external int _ghostty_terminal_continuation_alloc( + Terminal terminal, + ffi.Pointer allocator, + ffi.Pointer> out_ptr, + ffi.Pointer out_len, +); + +Result ghostty_terminal_continuation_alloc( + Terminal terminal, + ffi.Pointer allocator, + ffi.Pointer> out_ptr, + ffi.Pointer out_len, +) { + return Result.fromValue( + _ghostty_terminal_continuation_alloc(terminal, allocator, out_ptr, out_len), + ); +} + +/// Copy the terminal's replay-safe VT continuation into a caller buffer. +/// +/// Pass NULL for buf with buf_len zero to query the required size. A size query +/// returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written, +/// including zero when the stream is at ground. If a non-NULL buffer is too +/// small, the function has the same result and reports the full required size. +/// Continuation tracking must have been enabled by setting +/// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the +/// input that produced the continuation was written. +/// +/// The caller must serialize this operation with all other access to the same +/// terminal. +/// +/// @param terminal Terminal to read from (must not be NULL) +/// @param buf Destination buffer, or NULL when buf_len is zero +/// @param buf_len Destination buffer capacity in bytes +/// @param[out] out_written Bytes written, or required size on +/// GHOSTTY_OUT_OF_SPACE (must not be NULL) +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or +/// insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is +/// invalid, tracking is disabled, or the current continuation is +/// unavailable +/// +/// @ingroup terminal +@ffi.Native< + ffi.Int Function( + Terminal, + ffi.Pointer, + ffi.Size, + ffi.Pointer, + ) +>(symbol: 'ghostty_terminal_continuation_buf', isLeaf: true) +external int _ghostty_terminal_continuation_buf( + Terminal terminal, + ffi.Pointer buf, + int buf_len, + ffi.Pointer out_written, +); + +Result ghostty_terminal_continuation_buf( + Terminal terminal, + ffi.Pointer buf, + int buf_len, + ffi.Pointer out_written, +) { + return Result.fromValue( + _ghostty_terminal_continuation_buf(terminal, buf, buf_len, out_written), + ); +} + +/// Write the terminal's replay-safe VT continuation to a callback writer. +/// +/// The continuation is the exact byte suffix needed to reconstruct unfinished +/// VT parser or UTF-8 decoder state in an equivalent terminal. It is empty +/// when the stream is at ground. The callback is invoked synchronously and +/// may be called more than once. It must not call terminal APIs with the same +/// terminal handle. +/// +/// Continuation tracking must have been enabled by setting +/// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the +/// input that produced the continuation was written. +/// +/// The caller must serialize this operation with ghostty_terminal_vt_write() +/// and all other access to the same terminal. +/// +/// @param terminal Terminal to read from (must not be NULL) +/// @param writer Destination writer whose write callback must not be NULL +/// @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects +/// a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or +/// GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is +/// disabled, or the current continuation is unavailable +/// +/// @ingroup terminal +@ffi.Native( + symbol: 'ghostty_terminal_continuation_write', + isLeaf: true, +) +external int _ghostty_terminal_continuation_write( + Terminal terminal, + Writer writer, +); + +Result ghostty_terminal_continuation_write(Terminal terminal, Writer writer) { + return Result.fromValue( + _ghostty_terminal_continuation_write(terminal, writer), + ); +} + /// Free a terminal instance. /// /// Releases all resources associated with the terminal. After this call, @@ -4020,63 +4592,6 @@ Result ghostty_terminal_grid_ref_track( ); } -/// Get the current value of a terminal mode. -/// -/// Returns the value of the mode identified by the given mode. -/// -/// @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) -/// @param mode The mode identifying the mode to query -/// @param[out] out_value On success, set to true if the mode is set, false -/// if it is reset -/// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal -/// is NULL or the mode does not correspond to a known mode -/// -/// @ingroup terminal -@ffi.Native)>( - symbol: 'ghostty_terminal_mode_get', - isLeaf: true, -) -external int _ghostty_terminal_mode_get( - Terminal terminal, - int mode, - ffi.Pointer out_value, -); - -Result ghostty_terminal_mode_get( - Terminal terminal, - DartMode mode, - ffi.Pointer out_value, -) { - return Result.fromValue( - _ghostty_terminal_mode_get(terminal, mode, out_value), - ); -} - -/// Set the value of a terminal mode. -/// -/// Sets the mode identified by the given mode to the specified value. -/// -/// @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) -/// @param mode The mode identifying the mode to set -/// @param value true to set the mode, false to reset it -/// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal -/// is NULL or the mode does not correspond to a known mode -/// -/// @ingroup terminal -@ffi.Native( - symbol: 'ghostty_terminal_mode_set', - isLeaf: true, -) -external int _ghostty_terminal_mode_set( - Terminal terminal, - int mode, - bool value, -); - -Result ghostty_terminal_mode_set(Terminal terminal, DartMode mode, bool value) { - return Result.fromValue(_ghostty_terminal_mode_set(terminal, mode, value)); -} - /// Create a new terminal instance. /// /// The terminal starts with various reasonable defaults e.g. around @@ -6154,6 +6669,54 @@ final class PointValue extends ffi.Union { external ffi.Array _padding; } +/// A byte source callback and its opaque context. +/// +/// The struct is passed by value. @p read must be non-NULL. +final class Reader extends ffi.Struct { + external ReaderFn read; + + external ffi.Pointer userdata; + + static ffi.Pointer $allocate( + ffi.Allocator $allocator, { + required ReaderFn read, + required ffi.Pointer userdata, + }) => $allocator() + ..ref.read = read + ..ref.userdata = userdata; +} + +/// Read bytes from a source. +/// +/// The callback must set @p out_read to a value no greater than @p capacity +/// when returning true. A positive value reports progress; it may be less than +/// capacity and does not indicate end-of-file. A zero value is definitive +/// end-of-file. It must not be used to report temporary input starvation or a +/// would-block condition. +/// +/// Returning false reports a fatal read error and the value of @p out_read is +/// ignored. The library does not inspect or modify errno. +/// +/// All pointer arguments are borrowed and valid only for the duration of the +/// callback. The callback is invoked synchronously on the calling thread. +/// +/// @param userdata Opaque userdata from Reader +/// @param buffer Destination for read bytes; always non-NULL +/// @param capacity Writable capacity of @p buffer; always greater than zero +/// @param[out] out_read Number of bytes read when returning true; non-NULL +/// @return true for a successful read or end-of-file, false for a fatal error +typedef ReaderFn = + ffi.Pointer< + ffi.NativeFunction< + ffi.Bool Function( + ffi.Pointer userdata, + ffi.Pointer buffer, + ffi.Size capacity, + ffi.Pointer out_read, + ) + > + >; + /// Opaque handle to a render state instance. /// /// @ingroup render @@ -6531,6 +7094,13 @@ final class SizeReportSize extends ffi.Struct { ..ref.cell_height = cell_height; } +/// Opaque handle to an incremental terminal snapshot decoder. +/// +/// @ingroup snapshot +typedef SnapshotDecoder = ffi.Pointer; + +final class SnapshotDecoderImpl extends ffi.Opaque {} + /// A borrowed byte string (pointer + length). /// /// The memory is not owned by this struct. The pointer is only valid @@ -6910,6 +7480,32 @@ typedef TerminalEnquiryFn = final class TerminalImpl extends ffi.Opaque {} +/// A terminal mode and boolean value used for mode configuration and queries. +/// +/// For GHOSTTY_TERMINAL_DATA_MODE, initialize `mode` before calling +/// ghostty_terminal_get(). On success, `value` contains the current mode value. +/// +/// This struct has a frozen layout and will not gain fields in future versions. +/// +/// @ingroup terminal +final class TerminalModeConfig extends ffi.Struct { + /// Mode to configure or query. + @Mode() + external int mode; + + /// Value to set, or the current value returned by a query. + @ffi.Bool() + external bool value; + + static ffi.Pointer $allocate( + ffi.Allocator $allocator, { + required int mode, + required bool value, + }) => $allocator() + ..ref.mode = mode + ..ref.value = value; +} + /// A progress report emitted by the running program. /// /// This is a sized struct. The callback must only access fields present in the @@ -7282,3 +7878,51 @@ typedef TerminalXtversionFn = typedef TrackedGridRef = ffi.Pointer; final class TrackedGridRefImpl extends ffi.Opaque {} + +/// A byte destination callback and its opaque context. +/// +/// The struct is passed by value. @p write must be non-NULL. +final class Writer extends ffi.Struct { + external WriterFn write; + + external ffi.Pointer userdata; + + static ffi.Pointer $allocate( + ffi.Allocator $allocator, { + required WriterFn write, + required ffi.Pointer userdata, + }) => $allocator() + ..ref.write = write + ..ref.userdata = userdata; +} + +/// Write bytes to a destination. +/// +/// Returning true means all @p len bytes were accepted. Returning false +/// reports a fatal write error. A callback wrapping an interface that permits +/// partial writes must retry internally until the full slice is accepted or +/// an error occurs. +/// +/// On failure, the destination may already contain a prefix of the bytes. The +/// calling operation fails and must not be resumed from that partial output. +/// The library does not inspect or modify errno. +/// +/// @p data is borrowed and valid only for the duration of the callback. The +/// callback is invoked synchronously on the calling thread. Successful return +/// means the bytes were handed to the destination; it does not imply that the +/// destination was flushed or made durable. +/// +/// @param userdata Opaque userdata from Writer +/// @param data Source bytes; always non-NULL +/// @param len Number of source bytes; always greater than zero +/// @return true if the complete slice was accepted, false on fatal error +typedef WriterFn = + ffi.Pointer< + ffi.NativeFunction< + ffi.Bool Function( + ffi.Pointer userdata, + ffi.Pointer data, + ffi.Size len, + ) + > + >; diff --git a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart index f2e4b6de..a49d052c 100644 --- a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart @@ -913,7 +913,8 @@ enum KittyGraphicsImageData { compression(6), /// Borrowed pointer to the raw pixel data. Valid as long as the - /// underlying terminal is not mutated. + /// underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when + /// the image metadata is resident but its pixel payload is pending. /// /// The data is always fully decoded, uncompressed pixels in the /// format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads @@ -925,7 +926,9 @@ enum KittyGraphicsImageData { dataPtr(7), /// Length of the raw pixel data in bytes. Always equal to - /// width * height * bytes-per-pixel for the reported format. + /// width * height * bytes-per-pixel for the reported format. For a + /// pending image, this is the expected length reserved against the + /// storage limit even though DATA_PTR is not available yet. /// /// Output type: size_t * dataLen(8), @@ -940,7 +943,10 @@ enum KittyGraphicsImageData { /// Stamps are unique and monotonically increasing process-wide and /// are drawn from the same sequence as /// GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored - /// image, so zero can be used as an "empty" sentinel by callers. + /// image, so zero can be used as an "empty" sentinel by callers. Pending + /// payload completion preserves this value to retain image age; consumers + /// detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION + /// and retry DATA_PTR. /// /// Output type: uint64_t * generation(9); @@ -1823,7 +1829,13 @@ enum Result { outOfSpace(-3), /// The requested value has no value - noValue(-4); + noValue(-4), + + /// Operation failed while reading from or writing to external I/O + ioError(-5), + + /// Operation failed because encoded input exceeded a configured limit + limitExceeded(-6); final int value; const Result(this.value); @@ -1834,6 +1846,8 @@ enum Result { -2 => invalidValue, -3 => outOfSpace, -4 => noValue, + -5 => ioError, + -6 => limitExceeded, _ => throw ArgumentError('Unknown value for Result: $value'), }; } @@ -2357,6 +2371,114 @@ enum SizeReportStyle { }; } +/// Queryable snapshot decoder data. +/// +/// Each variant documents the output pointer type expected by +/// ghostty_snapshot_decoder_get(). +enum SnapshotDecoderData { + /// Invalid data type. Never results in data extraction. + invalid(0), + + /// Current maximum accepted continuation size. + /// + /// This value is available in every non-failed decoder state. + /// + /// Output type: size_t * + maxContinuationBytes(1), + + /// Number of snapshot source bytes consumed so far. + /// + /// At FINISH this identifies the first byte after the snapshot. Trailing + /// bytes are not consumed. This value is unavailable after a decoding error, + /// because the decoder can no longer guarantee its source position. + /// + /// Output type: size_t * + sourceOffset(2), + + /// Advisory complete logical history extent for the primary screen. + /// + /// The value counts rows before the active area, including any resident + /// overlap carried before READY. It becomes available after READY validates. + /// + /// Output type: uint64_t * + historyRowsPrimary(3), + + /// Advisory complete logical history extent for the alternate screen. + /// + /// The value has the same semantics and lifetime as + /// GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY. Querying it returns + /// GHOSTTY_NO_VALUE when the snapshot does not declare an alternate screen. + /// + /// Output type: uint64_t * + historyRowsAlternate(4), + + /// Screen associated with the most recently decoded history page. + /// + /// This value is available only after ghostty_snapshot_decoder_next() + /// returns GHOSTTY_SUCCESS. A later call to next replaces it or clears it + /// when FINISH is reached or an error occurs. + /// + /// Output type: TerminalScreen * + progressScreen(5), + + /// Rows prepended by the most recently decoded history page. + /// + /// Zero means the page was consumed and validated but could not be + /// applied to the live terminal. + /// + /// Output type: size_t * + progressRows(6), + + /// Page records remaining in the same screen's HISTORY sequence. + /// + /// This is not a count of all pages remaining in the snapshot. + /// + /// Output type: uint32_t * + progressRemaining(7); + + final int value; + const SnapshotDecoderData(this.value); + + static SnapshotDecoderData fromValue(int value) => switch (value) { + 0 => invalid, + 1 => maxContinuationBytes, + 2 => sourceOffset, + 3 => historyRowsPrimary, + 4 => historyRowsAlternate, + 5 => progressScreen, + 6 => progressRows, + 7 => progressRemaining, + _ => throw ArgumentError('Unknown value for SnapshotDecoderData: $value'), + }; +} + +/// Configurable snapshot decoder options. +/// +/// Options may only be changed before decoding starts. Calling +/// ghostty_snapshot_decoder_set() after ghostty_snapshot_decoder_ready() or +/// ghostty_snapshot_decoder_decode() returns GHOSTTY_INVALID_VALUE. +enum SnapshotDecoderOption { + /// Largest non-ground continuation the decoder will accept. + /// + /// A value of zero accepts only snapshots whose VT parser is in the ground + /// state. The decoder default matches the largest built-in APC protocol + /// buffer limit, currently 65 MiB. + /// + /// This is an input validation limit only. It does not configure continuation + /// tracking on a terminal returned by the decoder. + /// + /// Input type: size_t * + continuationBytes(0); + + final int value; + const SnapshotDecoderOption(this.value); + + static SnapshotDecoderOption fromValue(int value) => switch (value) { + 0 => continuationBytes, + _ => throw ArgumentError('Unknown value for SnapshotDecoderOption: $value'), + }; +} + /// Style color tags. /// /// These values identify the type of color in a style color. @@ -2792,7 +2914,25 @@ enum TerminalData { /// configured line limit is unlimited. /// /// Output type: size_t * - scrollbackMaxLines(35); + scrollbackMaxLines(35), + + /// The configured maximum retained VT continuation size in bytes. + /// + /// A value of zero means continuation tracking is disabled. This reports the + /// configured limit even when a current unfinished continuation is + /// temporarily unavailable. + /// + /// Output type: size_t * + continuationMaxBytes(36), + + /// Get the current value of a terminal mode. + /// + /// The caller must initialize the `mode` field. On success, the `value` field + /// is updated with the current value. A NULL pointer or unknown mode returns + /// GHOSTTY_INVALID_VALUE. + /// + /// Input/output type: TerminalModeConfig * + mode(37); final int value; const TerminalData(this.value); @@ -2834,6 +2974,8 @@ enum TerminalData { 33 => vtProcessingError, 34 => scrollbackMaxBytes, 35 => scrollbackMaxLines, + 36 => continuationMaxBytes, + 37 => mode, _ => throw ArgumentError('Unknown value for TerminalData: $value'), }; } @@ -3106,7 +3248,56 @@ enum TerminalOption { /// Set to NULL to ignore progress reports. /// /// Input type: TerminalProgressReportFn - progressReport(30); + progressReport(30), + + /// Set the maximum number of replay-safe VT continuation bytes retained. + /// + /// Continuation bytes reconstruct an escape sequence or UTF-8 codepoint + /// which was unfinished at the end of the most recent + /// ghostty_terminal_vt_write() call. They are used automatically by terminal + /// snapshots and may also be exported directly with the continuation APIs. + /// + /// Tracking is disabled by default. A nonzero value enables tracking and + /// sets its byte limit. Passing NULL or a pointer to zero disables tracking. + /// Lowering the limit below an already-retained + /// continuation, or enabling tracking while the parser is already + /// unfinished, makes the current continuation unavailable because earlier + /// bytes cannot be reconstructed. Tracking recovers automatically after a + /// later write reaches the ground state or contains a fresh replay start. + /// + /// Input type: size_t* + continuationMaxBytes(31), + + /// Enable window title reports in response to CSI 21 t. + /// + /// This is disabled by default because a running program can set a title and + /// query it back into the pty input stream, potentially injecting commands + /// that execute after user interaction. Passing NULL or a pointer to false + /// disables title reporting. + /// + /// Input type: bool* + titleReport(32), + + /// Set the reset default for a terminal mode. + /// + /// This unconditionally updates both the current value and the value restored + /// by a full terminal reset (RIS). + /// + /// Some recognized modes represent transitions or mirror additional terminal + /// state and cannot safely be configured as reset defaults. Those modes return + /// GHOSTTY_INVALID_VALUE. A NULL value pointer also returns + /// GHOSTTY_INVALID_VALUE. + /// + /// Input type: TerminalModeConfig* + modeDefault(33), + + /// Set the current value of a terminal mode. + /// + /// This does not change the value restored by a full terminal reset (RIS). + /// A NULL value pointer or unknown mode returns GHOSTTY_INVALID_VALUE. + /// + /// Input type: TerminalModeConfig* + mode(34); final int value; const TerminalOption(this.value); @@ -3143,6 +3334,10 @@ enum TerminalOption { 28 => scrollbackMaxLines, 29 => desktopNotification, 30 => progressReport, + 31 => continuationMaxBytes, + 32 => titleReport, + 33 => modeDefault, + 34 => mode, _ => throw ArgumentError('Unknown value for TerminalOption: $value'), }; } diff --git a/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart b/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart index 02d71399..c851055c 100644 --- a/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty_wasm.g.dart @@ -2248,6 +2248,270 @@ extension type GhosttyExports(JSObject _) implements JSObject { Pointer out_written, ); + /// Decode and validate one complete snapshot. + /// + /// This is the one-shot form of READY followed by all history pages through + /// FINISH. It may only be called before decoding starts. Bytes following FINISH + /// are left unread. On success terminal receives a caller-owned terminal with + /// its persistent VT stream restored. Continuation tracking on the returned + /// terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES + /// returns zero. terminal is set to NULL on every error. + /// A decoding, I/O, or allocation error after input consumption begins poisons + /// the decoder, after which it must be freed. An invalid argument or + /// lifecycle error detected before the operation consumes input does not + /// poison it. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @param[out] terminal Pointer to receive the terminal (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_decode(int decoder, Pointer terminal); + + /// Free a snapshot decoder. + /// + /// This does not release the caller's ownership of a terminal returned by + /// ready or decode. Abandoning an incremental decode leaves that terminal + /// usable with whatever history had already been restored. + /// + /// @param decoder Decoder to free (may be NULL) + /// + /// @ingroup snapshot + external void ghostty_snapshot_decoder_free(int decoder); + + /// Get typed data from a snapshot decoder. + /// + /// The output pointer must have the type documented by data. A phase-dependent + /// value that is not currently available returns GHOSTTY_NO_VALUE. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @param data Data kind to query + /// @param[out] out Pointer to receive the value (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + /// is unavailable, or another error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_get(int decoder, int data, Pointer out); + + /// Get multiple snapshot decoder data fields in a single call. + /// + /// Each keys element selects a data kind and the corresponding values element + /// points to storage of the documented output type. Processing stops at the + /// first error. On success out_written is set to count; on error it is set to + /// the number of values written before the failing key. Invalid array arguments + /// report zero values written. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @param count Number of key/value pairs + /// @param keys Array of data kinds to query + /// @param values Array of output pointers corresponding to keys + /// @param[out] out_written Number of successfully written values (may be NULL) + /// @return GHOSTTY_SUCCESS if every query succeeds, or the first error + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_get_multi( + int decoder, + int count, + Pointer keys, + Pointer values, + Pointer out_written, + ); + + /// Create a snapshot decoder that reads from a caller-provided reader. + /// + /// The decoder stores a copy of reader. Its read callback must not be NULL, and + /// both the callback and its caller-owned context must remain valid until + /// FINISH is reached or the decoder is freed. Reads are synchronous and occur + /// only during ready, next, or decode calls. A zero-byte successful read is + /// permanent end-of-file, not temporary starvation; nonblocking sources must + /// wait outside the decoder or block in their callback. The read callback must + /// not call APIs, including ghostty_snapshot_decoder_free(), on the decoder + /// that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with + /// zero bytes before a required marker reports truncated snapshot data as + /// GHOSTTY_INVALID_VALUE. + /// + /// @param allocator Allocator for decoder and decoded terminal state, or NULL + /// for the default allocator + /// @param decoder Pointer to receive the decoder handle (must not be NULL) + /// @param reader Snapshot source reader + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_new( + Pointer allocator, + Pointer decoder, + int reader, + ); + + /// Create a snapshot decoder over a borrowed byte buffer. + /// + /// The bytes are not copied. ptr must remain valid and immutable until FINISH + /// is reached or the decoder is freed. Bytes after FINISH are not consumed; + /// query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them. + /// + /// @param allocator Allocator for decoder and decoded terminal state, or NULL + /// for the default allocator + /// @param decoder Pointer to receive the decoder handle (must not be NULL) + /// @param ptr Snapshot source bytes + /// @param len Number of source bytes + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_new_buf( + Pointer allocator, + Pointer decoder, + Pointer ptr, + int len, + ); + + /// Decode one history page into the terminal returned by READY. + /// + /// Each GHOSTTY_SUCCESS consumes and validates one PAGE record. Query the + /// GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again. + /// GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH + /// also return GHOSTTY_NO_VALUE. + /// + /// The terminal may be rendered, resized, and fed live PTY input between calls. + /// If a history page can no longer be applied safely, it is still consumed and + /// validated and progress reports zero rows. The decoder applies history + /// to the caller-owned terminal produced by its READY operation. + /// + /// A decoding error invalidates the decoder's source position. The terminal + /// remains caller-owned and usable with its already-restored history, but only + /// ghostty_snapshot_decoder_free() may subsequently be called on the decoder. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an + /// error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_next(int decoder); + + /// Decode and validate the renderable snapshot prefix through READY. + /// + /// On success, terminal receives a caller-owned terminal with its persistent + /// VT stream already restored from the snapshot continuation. The terminal is + /// immediately usable for rendering and live input. Older scrollback remains + /// to be restored with ghostty_snapshot_decoder_next(). + /// + /// The restored parser state may be unfinished, but terminal continuation + /// tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns + /// zero. The decoder's continuation option is an input limit, not terminal + /// runtime policy. + /// + /// The caller must keep the returned terminal alive until FINISH validates or + /// the decoder is freed. The decoder borrows this terminal handle while it + /// restores history; ghostty_snapshot_decoder_next() uses it automatically. + /// + /// This operation may only be called once and only before decoding starts. + /// terminal is set to NULL on every error. A decoding, I/O, or allocation + /// error after input consumption begins poisons the decoder, after which it + /// must be freed. An invalid argument or lifecycle error detected before the + /// operation consumes input does not poison it. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @param[out] terminal Pointer to receive the terminal (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_ready(int decoder, Pointer terminal); + + /// Set a snapshot decoder option. + /// + /// The value pointer must have the type documented by option. Options may only + /// be changed before decoding starts. + /// + /// @param decoder Decoder handle (must not be NULL) + /// @param option Option to change + /// @param value Pointer to the option value (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has + /// started or an argument is invalid, or another error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_decoder_set( + int decoder, + int option, + Pointer value, + ); + + /// Encode a complete terminal snapshot to a writer. + /// + /// The terminal's persistent VT stream supplies the continuation bytes needed + /// to reconstruct unfinished parser state. The caller must prevent concurrent + /// writes or other terminal mutation for the duration of this call. The writer + /// callback must not call terminal APIs with the same terminal handle. + /// A terminal can be encoded with tracking disabled when its VT parser and + /// UTF-8 decoder are both at ground. If either is unfinished, tracking must + /// have been enabled before the input that produced that state was written; + /// otherwise this returns GHOSTTY_INVALID_VALUE. + /// + /// Encoding begins at the writer's current position. If an error occurs, the + /// writer may contain a partial snapshot without a valid FINISH marker. + /// Calls to the writer are synchronous; this function does not flush or make + /// the caller's destination durable. + /// + /// @param terminal Terminal to encode (must not be NULL) + /// @param writer Destination writer whose write callback must not be NULL + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects + /// output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + /// another error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_encode(int terminal, int writer); + + /// Encode a complete terminal snapshot to an allocated buffer. + /// + /// The returned buffer is allocated with allocator, or the default allocator + /// when allocator is NULL. The caller must release it with ghostty_free(), + /// passing the same allocator used here. + /// + /// A terminal can be encoded with tracking disabled when its VT parser and + /// UTF-8 decoder are both at ground. If either is unfinished, tracking must + /// have been enabled before the input that produced that state was written; + /// otherwise this returns GHOSTTY_INVALID_VALUE. + /// + /// @param terminal Terminal to encode (must not be NULL) + /// @param allocator Allocator for the output, or NULL for the default allocator + /// @param[out] out_ptr Allocated snapshot bytes (must not be NULL) + /// @param[out] out_len Number of allocated snapshot bytes (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_encode_alloc( + int terminal, + Pointer allocator, + Pointer out_ptr, + Pointer out_len, + ); + + /// Encode a complete terminal snapshot to a caller-provided buffer. + /// + /// Pass NULL for buf with buf_len zero to query the required size. If the + /// buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the + /// required capacity in out_written. A non-NULL undersized buffer may contain + /// a partial snapshot prefix. On success, out_written receives the number of + /// bytes encoded. + /// + /// A terminal can be encoded with tracking disabled when its VT parser and + /// UTF-8 decoder are both at ground. If either is unfinished, tracking must + /// have been enabled before the input that produced that state was written; + /// otherwise this returns GHOSTTY_INVALID_VALUE. + /// + /// @param terminal Terminal to encode (must not be NULL) + /// @param buf Destination buffer, or NULL when buf_len is zero + /// @param buf_len Destination buffer capacity in bytes + /// @param[out] out_written Bytes written, or required capacity on + /// GHOSTTY_OUT_OF_SPACE (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, or an error code on failure + /// + /// @ingroup snapshot + external int ghostty_snapshot_encode_buf( + int terminal, + Pointer buf, + int buf_len, + Pointer out_written, + ); + /// Get the default style. /// /// Initializes the style to the default values (no colors, no flags). @@ -2349,6 +2613,91 @@ extension type GhosttyExports(JSObject _) implements JSObject { Pointer out_activity, ); + /// Return an allocated copy of the terminal's replay-safe VT continuation. + /// + /// The returned bytes are allocated with allocator, or the default allocator + /// when allocator is NULL. The caller must release them with ghostty_free(), + /// passing the same allocator and returned length. An empty continuation is a + /// successful zero-length allocation. + /// Continuation tracking must have been enabled by setting + /// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + /// input that produced the continuation was written. + /// + /// The caller must serialize this operation with all other access to the same + /// terminal. + /// + /// @param terminal Terminal to read from (must not be NULL) + /// @param allocator Allocator for the output, or NULL for the default allocator + /// @param[out] out_ptr Allocated continuation bytes (must not be NULL) + /// @param[out] out_len Number of continuation bytes (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + /// failure, or GHOSTTY_INVALID_VALUE if an argument is invalid, + /// tracking is disabled, or the current continuation is unavailable + /// + /// @ingroup terminal + external int ghostty_terminal_continuation_alloc( + int terminal, + Pointer allocator, + Pointer out_ptr, + Pointer out_len, + ); + + /// Copy the terminal's replay-safe VT continuation into a caller buffer. + /// + /// Pass NULL for buf with buf_len zero to query the required size. A size query + /// returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written, + /// including zero when the stream is at ground. If a non-NULL buffer is too + /// small, the function has the same result and reports the full required size. + /// Continuation tracking must have been enabled by setting + /// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + /// input that produced the continuation was written. + /// + /// The caller must serialize this operation with all other access to the same + /// terminal. + /// + /// @param terminal Terminal to read from (must not be NULL) + /// @param buf Destination buffer, or NULL when buf_len is zero + /// @param buf_len Destination buffer capacity in bytes + /// @param[out] out_written Bytes written, or required size on + /// GHOSTTY_OUT_OF_SPACE (must not be NULL) + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or + /// insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is + /// invalid, tracking is disabled, or the current continuation is + /// unavailable + /// + /// @ingroup terminal + external int ghostty_terminal_continuation_buf( + int terminal, + Pointer buf, + int buf_len, + Pointer out_written, + ); + + /// Write the terminal's replay-safe VT continuation to a callback writer. + /// + /// The continuation is the exact byte suffix needed to reconstruct unfinished + /// VT parser or UTF-8 decoder state in an equivalent terminal. It is empty + /// when the stream is at ground. The callback is invoked synchronously and + /// may be called more than once. It must not call terminal APIs with the same + /// terminal handle. + /// + /// Continuation tracking must have been enabled by setting + /// GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the + /// input that produced the continuation was written. + /// + /// The caller must serialize this operation with ghostty_terminal_vt_write() + /// and all other access to the same terminal. + /// + /// @param terminal Terminal to read from (must not be NULL) + /// @param writer Destination writer whose write callback must not be NULL + /// @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects + /// a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + /// GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is + /// disabled, or the current continuation is unavailable + /// + /// @ingroup terminal + external int ghostty_terminal_continuation_write(int terminal, int writer); + /// Free a terminal instance. /// /// Releases all resources associated with the terminal. After this call, @@ -2469,37 +2818,6 @@ extension type GhosttyExports(JSObject _) implements JSObject { Pointer out_ref, ); - /// Get the current value of a terminal mode. - /// - /// Returns the value of the mode identified by the given mode. - /// - /// @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - /// @param mode The mode identifying the mode to query - /// @param[out] out_value On success, set to true if the mode is set, false - /// if it is reset - /// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal - /// is NULL or the mode does not correspond to a known mode - /// - /// @ingroup terminal - external int ghostty_terminal_mode_get( - int terminal, - int mode, - Pointer out_value, - ); - - /// Set the value of a terminal mode. - /// - /// Sets the mode identified by the given mode to the specified value. - /// - /// @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - /// @param mode The mode identifying the mode to set - /// @param value true to set the mode, false to reset it - /// @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal - /// is NULL or the mode does not correspond to a known mode - /// - /// @ingroup terminal - external int ghostty_terminal_mode_set(int terminal, int mode, int value); - /// Create a new terminal instance. /// /// The terminal starts with various reasonable defaults e.g. around diff --git a/packages/libghostty/test/bindings/bindings_native_test.dart b/packages/libghostty/test/bindings/bindings_native_test.dart index b022787d..05e462bf 100644 --- a/packages/libghostty/test/bindings/bindings_native_test.dart +++ b/packages/libghostty/test/bindings/bindings_native_test.dart @@ -298,6 +298,54 @@ void main() { isTrue, ); }); + + test('restores a configured mode default after reset', () { + checkCode( + bindings.terminalModeSetDefault( + terminal, + const TerminalMode.bracketedPaste().value, + value: true, + ), + ); + bindings.terminalReset(terminal); + + expect( + bindings + .terminalModeGet( + terminal, + const TerminalMode.bracketedPaste().value, + ) + .$2, + isTrue, + ); + }); + }); + + group('terminalSetTitleReport', () { + test('enables title reports', () { + final result = bindings.terminalSetTitleReport(terminal, enabled: true); + + expect(result, Result.success); + }); + }); + + group('terminalContinuationGet', () { + test('returns unfinished terminal input', () { + checkCode(bindings.terminalSetContinuationMaxBytes(terminal, 128)); + bindings.terminalVtWrite(terminal, Uint8List.fromList([0x1b, 0x5d])); + + final continuation = check(bindings.terminalContinuationGet(terminal)); + + expect(continuation, isNotEmpty); + }); + + test('reports the configured continuation limit', () { + checkCode(bindings.terminalSetContinuationMaxBytes(terminal, 128)); + + final limit = check(bindings.terminalGetContinuationMaxBytes(terminal)); + + expect(limit, 128); + }); }); group('terminalCompressionActivity', () { diff --git a/packages/libghostty/test/wasm/bindings_wasm_test.dart b/packages/libghostty/test/wasm/bindings_wasm_test.dart index 8558cd2b..d8332d2b 100644 --- a/packages/libghostty/test/wasm/bindings_wasm_test.dart +++ b/packages/libghostty/test/wasm/bindings_wasm_test.dart @@ -435,6 +435,54 @@ void main() { isTrue, ); }); + + test('restores a configured mode default after reset', () { + checkCode( + bindings.terminalModeSetDefault( + terminal, + const TerminalMode.bracketedPaste().value, + value: true, + ), + ); + bindings.terminalReset(terminal); + + expect( + bindings + .terminalModeGet( + terminal, + const TerminalMode.bracketedPaste().value, + ) + .$2, + isTrue, + ); + }); + }); + + group('terminalSetTitleReport', () { + test('enables title reports', () { + final result = bindings.terminalSetTitleReport(terminal, enabled: true); + + expect(result, Result.success); + }); + }); + + group('terminalContinuationGet', () { + test('returns unfinished terminal input', () { + checkCode(bindings.terminalSetContinuationMaxBytes(terminal, 128)); + bindings.terminalVtWrite(terminal, Uint8List.fromList([0x1b, 0x5d])); + + final continuation = check(bindings.terminalContinuationGet(terminal)); + + expect(continuation, isNotEmpty); + }); + + test('reports the configured continuation limit', () { + checkCode(bindings.terminalSetContinuationMaxBytes(terminal, 128)); + + final limit = check(bindings.terminalGetContinuationMaxBytes(terminal)); + + expect(limit, 128); + }); }); group('terminalReset', () { diff --git a/packages/libghostty/tool/ffigen.dart b/packages/libghostty/tool/ffigen.dart index f7a2bb91..0813b5f6 100644 --- a/packages/libghostty/tool/ffigen.dart +++ b/packages/libghostty/tool/ffigen.dart @@ -44,8 +44,8 @@ void main() { // C ABI sentinels (GHOSTTY_*_MAX_VALUE = INT_MAX) force enum sizing // but have no meaning in Dart and break exhaustive switches. ( - member: RegExp(r',\n\s+\w*[Mm]axValue\(2147483647\);'), - fromValueCase: RegExp(r'\n\s+2147483647 => \w*[Mm]axValue,'), + member: RegExp(r',\n\s+\w+\(2147483647\);'), + fromValueCase: RegExp(r'\n\s+2147483647 => \w+,'), ), ], ); From 01816713a603916856e25ec436c6b86e04f69831 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 7 Aug 2026 20:37:40 +0300 Subject: [PATCH 02/22] feat(libghostty): expose terminal mode and continuation options Expose reset-mode defaults, opt-in title reports, and replay-safe continuation bytes through the public Terminal API. Keep continuation limits explicit and preserve the upstream security default for title queries. --- .../lib/src/impl/terminal/terminal.dart | 93 +++++++++++++++---- .../test/impl/terminal/terminal_test.dart | 64 +++++++++++++ .../test/wasm/terminal/terminal_test.dart | 64 +++++++++++++ 3 files changed, 205 insertions(+), 16 deletions(-) diff --git a/packages/libghostty/lib/src/impl/terminal/terminal.dart b/packages/libghostty/lib/src/impl/terminal/terminal.dart index eb885036..77e4ce25 100644 --- a/packages/libghostty/lib/src/impl/terminal/terminal.dart +++ b/packages/libghostty/lib/src/impl/terminal/terminal.dart @@ -59,6 +59,9 @@ part 'tracked_grid_ref.dart'; /// not interrupt terminal processing. After the initiating operation finishes, /// the first exception is rethrown with its original stack trace. /// +/// Title query responses are disabled by default. Enable them with +/// [setTitleReports] in addition to registering [onWritePty]. +/// /// ## Color Theme /// /// The terminal maintains two color layers for foreground, background, cursor, @@ -153,6 +156,40 @@ final class Terminal with Listenable { return check(bindings.terminalCompressionActivity(_handle)); } + /// Replay-safe bytes for the terminal's unfinished VT or UTF-8 input. + /// + /// This returns the exact byte suffix needed to reconstruct parser state in + /// an equivalent terminal. It does not contain screen, cursor, mode, or + /// scrollback state. Returns an empty list when input is complete. + /// + /// Throws [InvalidValueException] when tracking is disabled or the current + /// unfinished input cannot be reconstructed. Throws + /// [OutOfMemoryException] if the bytes cannot be allocated. Access this + /// property serially with [write] and other terminal operations. + Uint8List get continuation { + return check(bindings.terminalContinuationGet(_handle)); + } + + /// Maximum number of unfinished VT or UTF-8 bytes retained for + /// [continuation]. + /// + /// A value of zero disables continuation tracking. Tracking must be enabled + /// before the input that produces unfinished parser state is written. + int get continuationMaxBytes { + return check(bindings.terminalGetContinuationMaxBytes(_handle)); + } + + /// Sets the maximum number of unfinished VT or UTF-8 bytes retained for + /// [continuation]. + /// + /// Set to zero to disable continuation tracking. Lowering the limit can + /// make the current continuation unavailable. Enabling tracking after + /// unfinished input has already been written does not recover it. + set continuationMaxBytes(int value) { + RangeError.checkNotNegative(value, 'value'); + checkCode(bindings.terminalSetContinuationMaxBytes(_handle, value)); + } + /// Effective cursor color (OSC override if active, otherwise default). /// /// Returns null if no color is configured. @@ -334,6 +371,14 @@ final class Terminal with Listenable { bindings.terminalSetOnClipboardWrite(_handle, value); } + /// Registers a callback for color scheme queries (CSI ? 996 n). + /// + /// Return the current [ColorScheme], or null to silently ignore the query. + /// Fires synchronously during [write]. + set onColorScheme(ValueGetter? value) { + bindings.terminalSetOnColorScheme(_handle, value); + } + /// Registers a callback for OSC 9 and OSC 777 desktop notifications. /// /// Requests are untrusted terminal content. The callback decides whether and @@ -343,21 +388,6 @@ final class Terminal with Listenable { bindings.terminalSetOnDesktopNotification(_handle, value); } - /// Registers a callback for OSC 9;4 progress reports. - /// - /// Fires synchronously during [write]. Set to null to ignore reports. - set onProgressReport(TerminalProgressCallback? value) { - bindings.terminalSetOnProgressReport(_handle, value); - } - - /// Registers a callback for color scheme queries (CSI ? 996 n). - /// - /// Return the current [ColorScheme], or null to silently ignore the query. - /// Fires synchronously during [write]. - set onColorScheme(ValueGetter? value) { - bindings.terminalSetOnColorScheme(_handle, value); - } - /// Registers a callback for device attributes queries (CSI c / > c / = c). /// /// Return a [DeviceAttributesResponse], or null to silently ignore the query. @@ -374,6 +404,13 @@ final class Terminal with Listenable { bindings.terminalSetOnEnquiry(_handle, value); } + /// Registers a callback for OSC 9;4 progress reports. + /// + /// Fires synchronously during [write]. Set to null to ignore reports. + set onProgressReport(TerminalProgressCallback? value) { + bindings.terminalSetOnProgressReport(_handle, value); + } + /// Registers a callback for working-directory changes via OSC 7/9/1337. /// /// Query the new [pwd] after the callback returns. Fires synchronously @@ -402,7 +439,8 @@ final class Terminal with Listenable { /// /// Invoked when the terminal needs to send data back to the PTY, for /// example in response to device status reports or mode queries. The data is - /// owned by Dart and remains valid after the callback returns. Fires + /// owned by Dart and remains valid after the callback returns. Title query + /// responses also use this callback when [setTitleReports] is enabled. Fires /// synchronously during [write]. set onWritePty(ValueSetter? value) { bindings.terminalSetOnWritePty(_handle, value); @@ -608,6 +646,16 @@ final class Terminal with Listenable { checkCode(bindings.terminalModeSet(_handle, mode.value, value: value)); } + /// Sets the current and reset-default value of the given terminal [mode]. + /// + /// Some transition or mirrored modes cannot be configured as reset defaults + /// and throw [InvalidValueException]. + void modeSetDefault(TerminalMode mode, {required bool value}) { + checkCode( + bindings.terminalModeSetDefault(_handle, mode.value, value: value), + ); + } + /// Performs a full reset (RIS): resets modes, scrollback, scrolling region, /// and screen contents to defaults while preserving terminal dimensions. void reset() => bindings.terminalReset(_handle); @@ -817,6 +865,19 @@ final class Terminal with Listenable { checkCode(bindings.terminalSetKittyImageMediumTempFile(_handle, directory)); } + /// Enables or disables title reports in response to `CSI 21 t` queries. + /// + /// When enabled, the response containing the current title is sent through + /// [onWritePty]. This is disabled by default because a program can set a + /// title, query it, and inject the response into the PTY input stream. + /// Enable it only for trusted terminal workloads. + /// + /// This setting is independent of [onTitleChanged], which observes title + /// changes made by OSC 0 or OSC 2. + void setTitleReports({required bool enabled}) { + checkCode(bindings.terminalSetTitleReport(_handle, enabled: enabled)); + } + /// Feeds raw VT-encoded bytes into the terminal for processing. /// /// Malformed input is logged internally but does not corrupt state or throw. diff --git a/packages/libghostty/test/impl/terminal/terminal_test.dart b/packages/libghostty/test/impl/terminal/terminal_test.dart index 15b67ba6..5600ef33 100644 --- a/packages/libghostty/test/impl/terminal/terminal_test.dart +++ b/packages/libghostty/test/impl/terminal/terminal_test.dart @@ -80,6 +80,38 @@ void main() { }); }); + group('continuationMaxBytes', () { + test('gets the value set through the setter', () { + terminal.continuationMaxBytes = 128; + + expect(terminal.continuationMaxBytes, 128); + }); + + test('rejects negative values', () { + expect( + () => terminal.continuationMaxBytes = -1, + throwsA(isA()), + ); + }); + }); + + group('continuation', () { + test('returns unfinished input after tracking is enabled', () { + terminal.continuationMaxBytes = 128; + + terminal.write(Uint8List.fromList([0x1b, 0x5d])); + + expect(terminal.continuation, [0x1b, 0x5d]); + }); + + test('throws when tracking is disabled', () { + expect( + () => terminal.continuation, + throwsA(isA()), + ); + }); + }); + group('onDesktopNotification', () { test('receives OSC 9 notifications', () { DesktopNotification? notification; @@ -475,6 +507,15 @@ void main() { expect(terminal.modeGet(const .insert()), isFalse); }); + test('restores a configured default after reset', () { + terminal.modeSetDefault(const .bracketedPaste(), value: true); + terminal.modeSet(const .bracketedPaste(), value: false); + + terminal.reset(); + + expect(terminal.modeGet(const .bracketedPaste()), isTrue); + }); + group('mouseTracking', () { test('default is none', () { expect(terminal.mouseTracking, MouseTracking.none); @@ -1098,6 +1139,29 @@ void main() { }); }); + group('setTitleReports', () { + test('does not report titles by default', () { + Uint8List? output; + terminal.title = 'example'; + terminal.onWritePty = (data) => output = data; + + terminal.write(Uint8List.fromList('\x1b[21t'.codeUnits)); + + expect(output, isNull); + }); + + test('writes the title when reports are enabled', () { + Uint8List? output; + terminal.title = 'example'; + terminal.onWritePty = (data) => output = data; + terminal.setTitleReports(enabled: true); + + terminal.write(Uint8List.fromList('\x1b[21t'.codeUnits)); + + expect(String.fromCharCodes(output!), contains('example')); + }); + }); + group('onDeviceAttributes', () { String responseFor( String request, diff --git a/packages/libghostty/test/wasm/terminal/terminal_test.dart b/packages/libghostty/test/wasm/terminal/terminal_test.dart index c7790cfe..b7238044 100644 --- a/packages/libghostty/test/wasm/terminal/terminal_test.dart +++ b/packages/libghostty/test/wasm/terminal/terminal_test.dart @@ -60,6 +60,38 @@ void main() { }); }); + group('continuationMaxBytes', () { + test('gets the value set through the setter', () { + terminal.continuationMaxBytes = 128; + + expect(terminal.continuationMaxBytes, 128); + }); + + test('rejects negative values', () { + expect( + () => terminal.continuationMaxBytes = -1, + throwsA(isA()), + ); + }); + }); + + group('continuation', () { + test('returns unfinished input after tracking is enabled', () { + terminal.continuationMaxBytes = 128; + + terminal.write(Uint8List.fromList([0x1b, 0x5d])); + + expect(terminal.continuation, [0x1b, 0x5d]); + }); + + test('throws when tracking is disabled', () { + expect( + () => terminal.continuation, + throwsA(isA()), + ); + }); + }); + group('onDesktopNotification', () { test('receives OSC 9 notifications', () { DesktopNotification? notification; @@ -460,6 +492,15 @@ void main() { expect(terminal.modeGet(const .insert()), isFalse); }); + test('restores a configured default after reset', () { + terminal.modeSetDefault(const .bracketedPaste(), value: true); + terminal.modeSet(const .bracketedPaste(), value: false); + + terminal.reset(); + + expect(terminal.modeGet(const .bracketedPaste()), isTrue); + }); + group('mouseTracking', () { test('default is none', () { expect(terminal.mouseTracking, MouseTracking.none); @@ -1045,6 +1086,29 @@ void main() { }); }); + group('setTitleReports', () { + test('does not report titles by default', () { + Uint8List? output; + terminal.title = 'example'; + terminal.onWritePty = (data) => output = data; + + terminal.write(Uint8List.fromList('\x1b[21t'.codeUnits)); + + expect(output, isNull); + }); + + test('writes the title when reports are enabled', () { + Uint8List? output; + terminal.title = 'example'; + terminal.onWritePty = (data) => output = data; + terminal.setTitleReports(enabled: true); + + terminal.write(Uint8List.fromList('\x1b[21t'.codeUnits)); + + expect(String.fromCharCodes(output!), contains('example')); + }); + }); + group('onBell', () { test('fires on BEL character', () { var bellCount = 0; From 2b915a5d4e94db41314dee7f89b5fa50534c0b64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:46:35 +0000 Subject: [PATCH 03/22] ci(deps): bump dorny/paths-filter from 4.0.2 to 4.0.3 Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.2 to 4.0.3. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/checks.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cda48bd4..dc471b23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: ptyx: ${{ steps.filter.outputs.ptyx }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bab5190a..a8488150 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -16,7 +16,7 @@ jobs: ptyx: ${{ steps.filter.outputs.ptyx }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | From 2dca54250b575452bd62d523c911d7d864bef129 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Thu, 13 Aug 2026 11:39:30 +0300 Subject: [PATCH 04/22] fix: support Flutter 3.47 analyzer changes Resolve lint-rule conflicts, migrate Material imports to the standalone material_ui package, and keep package analysis clean across the workspace. --- all_lint_rules.yaml | 16 +++++- analysis_options.yaml | 50 ++++++++++++------- packages/flterm/example/lib/demo_page.dart | 2 +- packages/flterm/example/lib/main.dart | 2 +- packages/flterm/example/pubspec.yaml | 5 +- packages/flterm/pubspec.yaml | 1 + .../test/foundation/color_palette_test.dart | 2 +- .../test/foundation/dynamic_color_test.dart | 2 +- .../test/foundation/terminal_theme_test.dart | 2 +- .../terminal_frame_builder_test.dart | 6 +-- .../terminal_scroll_controller_test.dart | 2 +- .../test/widgets/terminal_view_test.dart | 2 +- .../lib/src/hook/ghostty_source.dart | 3 +- .../lib/src/hook/library_provider.dart | 3 +- .../test/bindings/bindings_native_test.dart | 14 ++---- .../test/impl/terminal/terminal_test.dart | 14 ++---- packages/ptyx/lib/src/impl/session.dart | 2 +- 17 files changed, 75 insertions(+), 53 deletions(-) diff --git a/all_lint_rules.yaml b/all_lint_rules.yaml index ea810d13..3d2f2500 100644 --- a/all_lint_rules.yaml +++ b/all_lint_rules.yaml @@ -13,6 +13,7 @@ linter: - always_use_package_imports - annotate_overrides - annotate_redeclares + - async_return_with_no_await - avoid_annotating_with_dynamic - avoid_bool_literals_in_conditional_expressions - avoid_catches_without_on_clauses @@ -77,15 +78,18 @@ linter: - document_ignores - empty_catches - empty_constructor_bodies + - empty_container_bodies - empty_statements - eol_at_end_of_file - exhaustive_cases - file_names - flutter_style_todos + - future_sync_value - hash_and_equals - implementation_imports - implicit_call_tearoffs - implicit_reopen + - initialize_in_field_declaration - invalid_case_patterns - invalid_runtime_check_with_js_interop_types - join_return_with_assignment @@ -97,16 +101,19 @@ linter: - lines_longer_than_80_chars - literal_only_boolean_expressions - matching_super_parameters + - migrate_design_widgets - missing_code_block_language_in_doc_comment - missing_whitespace_between_adjacent_strings - no_adjacent_strings_in_list - no_default_cases - no_duplicate_case_values + - no_dynamic_casts - no_leading_underscores_for_library_prefixes - no_leading_underscores_for_local_identifiers - no_literal_bool_comparisons - no_logic_in_create_state - - no_runtimeType_toString + - no_raw_types + - no_runtimetype_tostring - no_self_assignments - no_wildcard_variable_uses - non_constant_identifier_names @@ -167,6 +174,7 @@ linter: - remove_deprecations_in_breaking_versions - require_trailing_commas - secure_pubspec_urls + - simple_directive_paths - simplify_variable_pattern - sized_box_for_whitespace - sized_box_shrink_expand @@ -192,6 +200,7 @@ linter: - unnecessary_brace_in_string_interps - unnecessary_breaks - unnecessary_const + - unnecessary_const_in_enum_constructor - unnecessary_constructor_name - unnecessary_final - unnecessary_getters_setters @@ -208,12 +217,15 @@ linter: - unnecessary_nullable_for_final_variable_declarations - unnecessary_overrides - unnecessary_parenthesis + - unnecessary_primary_constructor_body - unnecessary_raw_strings - unnecessary_statements - unnecessary_string_escapes - unnecessary_string_interpolations - unnecessary_this + - unnecessary_this_alias - unnecessary_to_list_in_spreads + - unnecessary_type_name_in_constructor - unnecessary_unawaited - unnecessary_underscores - unreachable_from_main @@ -221,6 +233,7 @@ linter: - unsafe_variance - use_build_context_synchronously - use_colored_box + - use_declaring_parameters - use_decorated_box - use_enums - use_full_hex_values_for_flutter_colors @@ -241,4 +254,5 @@ linter: - use_to_and_as_if_applicable - use_truncating_division - valid_regexps + - var_with_no_type_annotation - void_checks diff --git a/analysis_options.yaml b/analysis_options.yaml index 5ae00f82..24cbc578 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,10 @@ analyzer: strict-raw-types: true errors: + # The shared rule list includes mutually exclusive rules and rules that + # may not be recognized by every supported SDK. + included_file_warning: ignore + # Correctness avoid_slow_async_io: error avoid_type_to_string: error @@ -14,12 +18,15 @@ analyzer: cancel_subscriptions: error close_sinks: error collection_methods_unrelated_type: error + conditional_uri_does_not_exist: error + depend_on_referenced_packages: error hash_and_equals: error literal_only_boolean_expressions: error missing_required_param: error missing_return: error no_duplicate_case_values: error no_self_assignments: error + only_throw_errors: error throw_in_finally: error unrelated_type_equality_checks: error unsafe_variance: error @@ -31,35 +38,41 @@ analyzer: use_build_context_synchronously: error exclude: + # Generated and platform-specific build output is outside package source. - "**/*.g.dart" - - "**/*.gr.dart" - - "**/*.freezed.dart" - - "**/*.config.dart" - - "**/*.gen.dart" - - "**/*.mocks.dart" - - "**/l10n/app_localizations*.dart" - - "**/generated/**" + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: - # Conflicting rules — disable the losing side of each pair. - always_specify_types: false # conflicts with omit_local_variable_types, omit_obvious_* - type_annotate_public_apis: false # conflicts with omit_obvious_property_types - always_use_package_imports: false # conflicts with prefer_relative_imports - prefer_double_quotes: false # conflicts with prefer_single_quotes - prefer_final_parameters: false # conflicts with avoid_final_parameters - unnecessary_final: false # conflicts with prefer_final_locals - - # Too strict or noisy for a Flutter app. + # Allow the workspace's existing inferred-type declaration style. + always_specify_types: false omit_local_variable_types: false - specify_nonobvious_property_types: false specify_nonobvious_local_variable_types: false + type_annotate_public_apis: false + + # Use relative internal imports and single-quoted strings across packages. + always_use_package_imports: false + prefer_double_quotes: false + + # Keep final-variable and parameter choices consistent across packages. + prefer_final_parameters: false + unnecessary_final: false + + # These rules are too noisy or too narrow for a reusable package workspace. always_put_control_body_on_new_line: false always_put_required_named_parameters_first: false avoid_classes_with_only_static_members: false + avoid_setters_without_getters: false cascade_invocations: false comment_references: false diagnostic_describe_all_properties: false + document_ignores: false flutter_style_todos: false one_member_abstracts: false prefer_expression_function_bodies: false @@ -68,5 +81,4 @@ linter: prefer_mixin: false public_member_api_docs: false sort_constructors_first: false - avoid_setters_without_getters: false - document_ignores: false + specify_nonobvious_property_types: false diff --git a/packages/flterm/example/lib/demo_page.dart b/packages/flterm/example/lib/demo_page.dart index 7a0f0212..d4004329 100644 --- a/packages/flterm/example/lib/demo_page.dart +++ b/packages/flterm/example/lib/demo_page.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flterm/flterm.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class DemoPage extends StatefulWidget { final TerminalTheme? theme; diff --git a/packages/flterm/example/lib/main.dart b/packages/flterm/example/lib/main.dart index ee422f59..6ea8211a 100644 --- a/packages/flterm/example/lib/main.dart +++ b/packages/flterm/example/lib/main.dart @@ -1,6 +1,6 @@ import 'package:flterm/flterm.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'demo_page.dart'; import 'themes.dart'; diff --git a/packages/flterm/example/pubspec.yaml b/packages/flterm/example/pubspec.yaml index feb497f8..4769c812 100644 --- a/packages/flterm/example/pubspec.yaml +++ b/packages/flterm/example/pubspec.yaml @@ -5,14 +5,15 @@ resolution: workspace publish_to: none environment: - sdk: ^3.10.0 - flutter: ">=3.32.0" + sdk: ^3.12.0 + flutter: ">=3.44.0" dependencies: flterm: path: .. flutter: sdk: flutter + material_ui: ^1.0.0 dev_dependencies: flutter_test: diff --git a/packages/flterm/pubspec.yaml b/packages/flterm/pubspec.yaml index cffa7415..b5f4886f 100644 --- a/packages/flterm/pubspec.yaml +++ b/packages/flterm/pubspec.yaml @@ -40,3 +40,4 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + material_ui: ^1.0.0 diff --git a/packages/flterm/test/foundation/color_palette_test.dart b/packages/flterm/test/foundation/color_palette_test.dart index bd4314d4..246aeecc 100644 --- a/packages/flterm/test/foundation/color_palette_test.dart +++ b/packages/flterm/test/foundation/color_palette_test.dart @@ -1,8 +1,8 @@ import 'package:flterm/src/foundation/color_palette.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show RgbColor, defaultColorPalette, generateColorPalette; +import 'package:material_ui/material_ui.dart'; void main() { group('ColorPalette', () { diff --git a/packages/flterm/test/foundation/dynamic_color_test.dart b/packages/flterm/test/foundation/dynamic_color_test.dart index 874d27de..31aa18e2 100644 --- a/packages/flterm/test/foundation/dynamic_color_test.dart +++ b/packages/flterm/test/foundation/dynamic_color_test.dart @@ -1,6 +1,6 @@ import 'package:flterm/src/foundation/dynamic_color.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { const cellFg = Color(0xFF112233); diff --git a/packages/flterm/test/foundation/terminal_theme_test.dart b/packages/flterm/test/foundation/terminal_theme_test.dart index 372b699c..09319651 100644 --- a/packages/flterm/test/foundation/terminal_theme_test.dart +++ b/packages/flterm/test/foundation/terminal_theme_test.dart @@ -1,7 +1,7 @@ import 'package:flterm/src/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('CursorTheme', () { diff --git a/packages/flterm/test/rendering/terminal_frame_builder_test.dart b/packages/flterm/test/rendering/terminal_frame_builder_test.dart index 30374264..a833a19a 100644 --- a/packages/flterm/test/rendering/terminal_frame_builder_test.dart +++ b/packages/flterm/test/rendering/terminal_frame_builder_test.dart @@ -186,7 +186,7 @@ void main() { test('sync resolves palette colors from render state colors', () { terminal.palette = [ for (var i = 0; i < 256; i++) - i == 1 ? const RgbColor(1, 2, 3) : const RgbColor(0, 0, 0), + if (i == 1) const RgbColor(1, 2, 3) else const RgbColor(0, 0, 0), ]; writeUtf8(terminal, '\x1b[31mA'); @@ -246,7 +246,7 @@ void main() { ); terminal.palette = [ for (var i = 0; i < 256; i++) - i == 1 ? const RgbColor(1, 2, 3) : const RgbColor(0, 0, 0), + if (i == 1) const RgbColor(1, 2, 3) else const RgbColor(0, 0, 0), ]; writeUtf8(terminal, '\x1b[31mA\x1b[1;1H'); @@ -258,7 +258,7 @@ void main() { test('sync resolves underline colors from render state colors', () { terminal.palette = [ for (var i = 0; i < 256; i++) - i == 1 ? const RgbColor(1, 2, 3) : const RgbColor(0, 0, 0), + if (i == 1) const RgbColor(1, 2, 3) else const RgbColor(0, 0, 0), ]; writeUtf8(terminal, '\x1b[4;58;5;1mA'); diff --git a/packages/flterm/test/widgets/terminal_scroll_controller_test.dart b/packages/flterm/test/widgets/terminal_scroll_controller_test.dart index c73d2155..ddf552c0 100644 --- a/packages/flterm/test/widgets/terminal_scroll_controller_test.dart +++ b/packages/flterm/test/widgets/terminal_scroll_controller_test.dart @@ -1,7 +1,7 @@ import 'package:flterm/src/widgets.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show TerminalScreen; +import 'package:material_ui/material_ui.dart'; void main() { Widget buildScrollable( diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/widgets/terminal_view_test.dart index 9259d079..c2207937 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/widgets/terminal_view_test.dart @@ -10,13 +10,13 @@ import 'package:flutter/foundation.dart' debugDefaultTargetPlatformOverride, defaultTargetPlatform; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide ColorScheme, KeyEvent; import 'package:libghostty/libghostty.dart' as vt show ColorScheme, ColorSchemeReportEncode; +import 'package:material_ui/material_ui.dart'; extension _SelectionEdges on Selection { Position get _startPoint => start.positionIn(.viewport)!; diff --git a/packages/libghostty/lib/src/hook/ghostty_source.dart b/packages/libghostty/lib/src/hook/ghostty_source.dart index befff016..449d233d 100644 --- a/packages/libghostty/lib/src/hook/ghostty_source.dart +++ b/packages/libghostty/lib/src/hook/ghostty_source.dart @@ -95,5 +95,6 @@ Future resolveSource({ final localGhostty = Directory.fromUri(workspaceRoot.resolve('ghostty/')); if (localGhostty.existsSync()) return localGhostty; - return downloadSource(cacheBase, packageRoot: packageRoot); + final directory = await downloadSource(cacheBase, packageRoot: packageRoot); + return directory; } diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index d0f20710..8ec491d9 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -203,10 +203,11 @@ final class CompileFromSource extends LibraryProvider { final localGhostty = Directory.fromUri(workspaceRoot.resolve('ghostty/')); if (localGhostty.existsSync()) return localGhostty; - return switch (downloadMethod) { + final directory = await switch (downloadMethod) { .tarball => _downloadTarball(), .git => _gitClone(), }; + return directory; } } diff --git a/packages/libghostty/test/bindings/bindings_native_test.dart b/packages/libghostty/test/bindings/bindings_native_test.dart index 05e462bf..706bdf86 100644 --- a/packages/libghostty/test/bindings/bindings_native_test.dart +++ b/packages/libghostty/test/bindings/bindings_native_test.dart @@ -382,15 +382,11 @@ void main() { expect(result, (Result.success, TerminalCompressionResult.unsupported)); }, testOn: 'windows'); - test( - 'completes full compression on supported targets', - () { - final result = bindings.terminalCompress(terminal, .full); - - expect(result, (Result.success, TerminalCompressionResult.complete)); - }, - testOn: 'linux || mac-os || android || ios', - ); + test('completes full compression on supported targets', () { + final result = bindings.terminalCompress(terminal, .full); + + expect(result, (Result.success, TerminalCompressionResult.complete)); + }, testOn: 'linux || mac-os || android || ios'); test('rejects an invalid handle', () { final result = bindings.terminalCompress(0, .incremental); diff --git a/packages/libghostty/test/impl/terminal/terminal_test.dart b/packages/libghostty/test/impl/terminal/terminal_test.dart index 5600ef33..9d6cf51d 100644 --- a/packages/libghostty/test/impl/terminal/terminal_test.dart +++ b/packages/libghostty/test/impl/terminal/terminal_test.dart @@ -677,15 +677,11 @@ void main() { expect(result, TerminalCompressionResult.unsupported); }, testOn: 'windows'); - test( - 'completes full compression on supported targets', - () { - final result = terminal.compress(mode: .full); - - expect(result, TerminalCompressionResult.complete); - }, - testOn: 'linux || mac-os || android || ios', - ); + test('completes full compression on supported targets', () { + final result = terminal.compress(mode: .full); + + expect(result, TerminalCompressionResult.complete); + }, testOn: 'linux || mac-os || android || ios'); }); group('onClipboardWrite', () { diff --git a/packages/ptyx/lib/src/impl/session.dart b/packages/ptyx/lib/src/impl/session.dart index 4174529b..1c2900e3 100644 --- a/packages/ptyx/lib/src/impl/session.dart +++ b/packages/ptyx/lib/src/impl/session.dart @@ -139,7 +139,7 @@ final class NativeSession implements PtySession { Future _closeOutput() async { if (_outputDone) return; _outputDone = true; - return _outputController.close(); + await _outputController.close(); } void _completeExitCodeError(PtyException error) { From bf6ea621973780369a223154af5ad7510011cc03 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Thu, 13 Aug 2026 13:58:54 +0300 Subject: [PATCH 05/22] refactor(flterm): establish controller view boundary --- packages/flterm/CHANGELOG.md | 29 + packages/flterm/README.md | 7 +- packages/flterm/lib/flterm.dart | 13 +- .../lib/src/controller/kitty_png_decoder.dart | 22 + .../src/controller/terminal_controller.dart | 369 ++ .../controller/terminal_controller_impl.dart | 737 ++++ packages/flterm/lib/src/foundation.dart | 3 +- .../flterm/lib/src/foundation/callbacks.dart | 34 - .../lib/src/foundation/input_types.dart | 22 - .../lib/src/foundation/terminal_config.dart | 35 +- .../lib/src/foundation/terminal_geometry.dart | 235 ++ .../foundation/terminal_render_observer.dart | 18 - .../src/input/terminal_gesture_detector.dart | 712 ++++ .../lib/src/input/terminal_input_adapter.dart | 265 ++ .../terminal_input_client.dart | 87 +- .../lib/src/input/terminal_input_encoder.dart | 126 + .../lib/src/input/terminal_input_event.dart | 107 + .../terminal_raw_gesture_detector.dart | 61 +- .../terminal_scroll_gesture_handler.dart | 683 +++ .../selection_gesture_driver.dart | 44 +- .../src/interaction/terminal_selection.dart | 356 ++ .../{widgets => links}/link_interaction.dart | 119 +- packages/flterm/lib/src/rendering.dart | 1 + .../lib/src/rendering/kitty_png_decoder.dart | 26 - .../src/rendering/terminal_frame_source.dart | 28 + .../lib/src/rendering/terminal_renderer.dart | 409 +- .../compression_scheduler.dart | 34 +- .../lib/src/view/terminal_cursor_blink.dart | 27 + .../src/{widgets => view}/terminal_scope.dart | 5 +- .../terminal_scroll_controller.dart | 24 +- .../terminal_shortcut_scope.dart | 14 +- .../src/{widgets => view}/terminal_view.dart | 464 +- .../src/view/terminal_view_attachment.dart | 221 + packages/flterm/lib/src/widgets.dart | 10 - .../lib/src/widgets/terminal_controller.dart | 255 -- .../src/widgets/terminal_controller_impl.dart | 1070 ----- .../widgets/terminal_gesture_detector.dart | 350 -- .../src/widgets/terminal_view_binding.dart | 116 - .../terminal_controller_test.dart | 901 +++- .../foundation/terminal_geometry_test.dart | 50 + .../input/terminal_gesture_detector_test.dart | 3737 +++++++++++++++++ .../terminal_input_client_test.dart | 319 +- .../selection_gesture_driver_test.dart | 98 + .../link_interaction_test.dart | 7 +- .../test/rendering/cursor_layer_test.dart | 20 +- .../test/rendering/emoji_golden_test.dart | 22 +- .../test/rendering/helpers/font_loader.dart | 23 + .../test/rendering/sprites_golden_test.dart | 22 +- .../rendering/terminal_frame_source_test.dart | 47 + .../terminal_renderer_golden_test.dart | 48 +- .../rendering/terminal_renderer_test.dart | 156 +- .../transparent_background_golden_test.dart | 22 +- .../compression_scheduler_test.dart | 2 +- .../test/view/terminal_cursor_blink_test.dart | 34 + .../terminal_scroll_controller_test.dart | 3 +- .../terminal_shortcut_scope_test.dart | 15 +- .../view/terminal_view_attachment_test.dart | 162 + .../{widgets => view}/terminal_view_test.dart | 1373 +++++- .../selection_gesture_driver_test.dart | 66 - .../terminal_gesture_detector_test.dart | 885 ---- .../widgets/terminal_view_binding_test.dart | 466 -- .../flterm/tool/benchmarks/frame/harness.dart | 17 +- .../benchmarks/frame/render_environment.dart | 34 +- 63 files changed, 11295 insertions(+), 4372 deletions(-) create mode 100644 packages/flterm/lib/src/controller/kitty_png_decoder.dart create mode 100644 packages/flterm/lib/src/controller/terminal_controller.dart create mode 100644 packages/flterm/lib/src/controller/terminal_controller_impl.dart delete mode 100644 packages/flterm/lib/src/foundation/callbacks.dart create mode 100644 packages/flterm/lib/src/foundation/terminal_geometry.dart delete mode 100644 packages/flterm/lib/src/foundation/terminal_render_observer.dart create mode 100644 packages/flterm/lib/src/input/terminal_gesture_detector.dart create mode 100644 packages/flterm/lib/src/input/terminal_input_adapter.dart rename packages/flterm/lib/src/{widgets => input}/terminal_input_client.dart (89%) create mode 100644 packages/flterm/lib/src/input/terminal_input_encoder.dart create mode 100644 packages/flterm/lib/src/input/terminal_input_event.dart rename packages/flterm/lib/src/{widgets => input}/terminal_raw_gesture_detector.dart (55%) create mode 100644 packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart rename packages/flterm/lib/src/{widgets => interaction}/selection_gesture_driver.dart (67%) create mode 100644 packages/flterm/lib/src/interaction/terminal_selection.dart rename packages/flterm/lib/src/{widgets => links}/link_interaction.dart (79%) delete mode 100644 packages/flterm/lib/src/rendering/kitty_png_decoder.dart create mode 100644 packages/flterm/lib/src/rendering/terminal_frame_source.dart rename packages/flterm/lib/src/{widgets => view}/compression_scheduler.dart (80%) create mode 100644 packages/flterm/lib/src/view/terminal_cursor_blink.dart rename packages/flterm/lib/src/{widgets => view}/terminal_scope.dart (86%) rename packages/flterm/lib/src/{widgets => view}/terminal_scroll_controller.dart (76%) rename packages/flterm/lib/src/{widgets => view}/terminal_shortcut_scope.dart (92%) rename packages/flterm/lib/src/{widgets => view}/terminal_view.dart (53%) create mode 100644 packages/flterm/lib/src/view/terminal_view_attachment.dart delete mode 100644 packages/flterm/lib/src/widgets.dart delete mode 100644 packages/flterm/lib/src/widgets/terminal_controller.dart delete mode 100644 packages/flterm/lib/src/widgets/terminal_controller_impl.dart delete mode 100644 packages/flterm/lib/src/widgets/terminal_gesture_detector.dart delete mode 100644 packages/flterm/lib/src/widgets/terminal_view_binding.dart rename packages/flterm/test/{widgets => controller}/terminal_controller_test.dart (55%) create mode 100644 packages/flterm/test/foundation/terminal_geometry_test.dart create mode 100644 packages/flterm/test/input/terminal_gesture_detector_test.dart rename packages/flterm/test/{widgets => input}/terminal_input_client_test.dart (82%) create mode 100644 packages/flterm/test/interaction/selection_gesture_driver_test.dart rename packages/flterm/test/{widgets => links}/link_interaction_test.dart (97%) create mode 100644 packages/flterm/test/rendering/terminal_frame_source_test.dart rename packages/flterm/test/{widgets => view}/compression_scheduler_test.dart (99%) create mode 100644 packages/flterm/test/view/terminal_cursor_blink_test.dart rename packages/flterm/test/{widgets => view}/terminal_scroll_controller_test.dart (97%) rename packages/flterm/test/{widgets => view}/terminal_shortcut_scope_test.dart (94%) create mode 100644 packages/flterm/test/view/terminal_view_attachment_test.dart rename packages/flterm/test/{widgets => view}/terminal_view_test.dart (55%) delete mode 100644 packages/flterm/test/widgets/selection_gesture_driver_test.dart delete mode 100644 packages/flterm/test/widgets/terminal_gesture_detector_test.dart delete mode 100644 packages/flterm/test/widgets/terminal_view_binding_test.dart diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index 8e72e18d..604e7ff3 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -2,11 +2,40 @@ ## Unreleased +### Breaking + +- **Renderer-neutral controller**: focus and soft-keyboard APIs move from + `TerminalController` to `TerminalView`. Pass a `FocusNode` for imperative + focus control and configure soft-keyboard access with `showKeyboard`. +- **Controller contract**: `TerminalController` supports one attached + `TerminalView`. A second concurrent attachment throws `StateError`. +- **Callback configuration**: `onBell`, `onOutput`, `onPwdChanged`, `onResize`, + and `onTitleChanged` are setter-only. Retain callback references if their + identity is needed elsewhere. +- **Removed public types**: `KeyboardState` and `TerminalScrollPosition` are no + longer exported. Use `FocusNode`, `TerminalView.showKeyboard`, and the + standard `ScrollPosition` interface. + +### Changed + +- **Resize lifecycle**: `onResize` reports only after `TerminalView` commits a + measured grid; assigning it later immediately reports that grid. + Cell-pixel-only changes skip the callback, and in-band output is emitted + first. + ### Fixed - **Text input recovery**: terminal clients reconnect when another input client takes the platform text input connection, including while composition is active. +- **Keyboard and IME input**: AltGr printable input no longer emits synthetic + Control or Alt bytes, Kitty keyboard reports include Caps Lock and Num Lock, + and newline/deletion deduplication no longer duplicates or suppresses input. +- **Pointer input**: mouse and stylus button changes remain accurate during a + gesture, stylus drags select text, and a gesture no longer switches between + selection and terminal mouse reporting. +- **View updates**: controller swaps transfer terminal focus correctly, and + changing `TerminalView.fontData` refreshes cell metrics. ## 0.0.5 diff --git a/packages/flterm/README.md b/packages/flterm/README.md index 3c94ec4e..626adb0b 100644 --- a/packages/flterm/README.md +++ b/packages/flterm/README.md @@ -21,7 +21,7 @@ libghostty-vt engine. keyboard on mobile, both on web. - `TerminalController` owns the terminal and connects to a backend (PTY, SSH, socket) via output/resize/bell/title callbacks. Helpers - for I/O, selection, focus, scrolling, paste, and mode toggling. + for I/O, selection, scrolling, paste, and mode toggling. - Drag, double-click, triple-click, and Alt+drag selection over wide characters (CJK, emoji, VS16, combining marks) with cell-snapped boundaries. @@ -72,6 +72,11 @@ TerminalView( ); ``` +Attach a controller to only one `TerminalView` at a time. The view does not +dispose it; remove the view, dispose any formatters created by the controller, +then dispose the controller when the terminal session ends. Resize callbacks +begin after the view has measured and committed its first grid. + The same controller drives the terminal programmatically: ```dart diff --git a/packages/flterm/lib/flterm.dart b/packages/flterm/lib/flterm.dart index dcebcbe6..fb121de2 100644 --- a/packages/flterm/lib/flterm.dart +++ b/packages/flterm/lib/flterm.dart @@ -33,11 +33,12 @@ export 'package:libghostty/libghostty.dart' UnderlineStyle, initializeForWeb; -export 'src/foundation/callbacks.dart' show OnResize; +export 'src/controller/terminal_controller.dart' + show OnResize, TerminalController; export 'src/foundation/cell_range.dart' show CellRange; export 'src/foundation/color_palette.dart' show ColorPalette; export 'src/foundation/dynamic_color.dart' show DynamicColor; -export 'src/foundation/input_types.dart' show KeyboardState, MouseAutoHide; +export 'src/foundation/input_types.dart' show MouseAutoHide; export 'src/foundation/terminal_config.dart' show ScrollToBottom, TerminalConfig; export 'src/foundation/terminal_gesture_settings.dart' @@ -62,8 +63,6 @@ export 'src/links/link_settings.dart' LinkSettings, LinkType, LinkedFile; -export 'src/widgets/terminal_controller.dart' show TerminalController; -export 'src/widgets/terminal_scope.dart' show TerminalScope; -export 'src/widgets/terminal_scroll_controller.dart' - show TerminalScrollController, TerminalScrollPosition; -export 'src/widgets/terminal_view.dart' show TerminalView; +export 'src/view/terminal_scope.dart' show TerminalScope; +export 'src/view/terminal_scroll_controller.dart' show TerminalScrollController; +export 'src/view/terminal_view.dart' show TerminalView; diff --git a/packages/flterm/lib/src/controller/kitty_png_decoder.dart b/packages/flterm/lib/src/controller/kitty_png_decoder.dart new file mode 100644 index 00000000..15169467 --- /dev/null +++ b/packages/flterm/lib/src/controller/kitty_png_decoder.dart @@ -0,0 +1,22 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' show decodePng; +import 'package:libghostty/libghostty.dart'; + +var _installed = false; + +/// Installs flterm's default PNG decoder for Kitty graphics. +/// +/// Installation is idempotent across terminal sessions. +void installDefaultKittyPngDecoder() { + if (_installed) return; + _installed = true; + LibGhostty.setPngDecoder(_decodePng); +} + +DecodedImage? _decodePng(Uint8List bytes) { + final decoded = decodePng(bytes); + if (decoded == null) return null; + final rgba = decoded.convert(format: .uint8, numChannels: 4); + return (width: rgba.width, height: rgba.height, rgba: rgba.toUint8List()); +} diff --git a/packages/flterm/lib/src/controller/terminal_controller.dart b/packages/flterm/lib/src/controller/terminal_controller.dart new file mode 100644 index 00000000..c9db39a3 --- /dev/null +++ b/packages/flterm/lib/src/controller/terminal_controller.dart @@ -0,0 +1,369 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' hide Key; +import 'package:libghostty/libghostty.dart' hide Listenable, TerminalGeometry; + +import '../foundation.dart'; +import '../input/terminal_input_encoder.dart'; +import '../input/terminal_input_event.dart'; +import '../interaction/terminal_selection.dart'; +import 'kitty_png_decoder.dart'; + +part 'terminal_controller_impl.dart'; + +/// Reports the committed terminal grid dimensions to the backend. +typedef OnResize = void Function(int cols, int rows); + +/// Manages terminal state and bridges it with [TerminalView]. +/// +/// Create a controller, wire up [onOutput] to your backend, pass the +/// controller to a [TerminalView], and feed backend data into [write]. +/// The controller handles terminal state, input encoding, selection, and +/// terminal scrolling. Flutter focus, text input, and viewport state belong +/// to [TerminalView]. +/// +/// A controller can be attached to only one [TerminalView] at a time. Remove +/// the current view before attaching another one. Dispose formatters returned +/// by [createFormatter], then dispose the controller when the session ends. +/// Controller operations must not be used after disposal. +/// +/// Callbacks caused by [write] run synchronously and block further terminal +/// input processing. They must remain brief and must not call [write] on this +/// controller reentrantly. If a callback throws, libghostty finishes processing +/// the current input before the exception is rethrown. +/// +/// ```dart +/// final controller = TerminalController() +/// ..onOutput = (bytes) => pty.write(bytes) +/// ..onBell = () => playSound() +/// ..onTitleChanged = () => updateTitle(controller.title); +/// +/// TerminalView(controller: controller); +/// +/// pty.onData = (bytes) => controller.write(bytes); +/// controller.sendText('ls -la\n'); +/// ``` +abstract class TerminalController extends ChangeNotifier { + /// Creates a controller with the given [config]. + /// + /// The terminal is created immediately with the initial dimensions, modes, + /// resource limits, and other behavior from [config]. + factory TerminalController({TerminalConfig config}) = TerminalControllerImpl; + + @internal + TerminalController.base(); + + /// The active [TerminalScreen] buffer, either primary or alternate. + /// + /// Full-screen programs such as vim, less, and htop commonly enter the + /// alternate screen with DEC private mode 1049. Scrollback is available + /// only on the primary screen. + TerminalScreen get activeScreen; + + /// The current controller configuration. + /// + /// The value contains the defaults applied by the controller. A program can + /// change live terminal modes with [modeSet], so mode state may differ from + /// [config]. + TerminalConfig get config; + + /// Replaces the configuration. + /// + /// Applies every entry in [TerminalConfig.modes] and updates the terminal's + /// resource limits, cursor defaults, query responses, and input policies + /// without recreating the terminal. Modes omitted from the new map retain + /// their live values. + /// + /// [TerminalConfig.cols] and [TerminalConfig.rows] are creation-only; a + /// connected [TerminalView] controls the live grid size. Lowering scrollback + /// limits can immediately prune history, setting a byte limit of zero clears + /// it, and disabling an image protocol can delete its stored resources. + set config(TerminalConfig config); + + /// Whether the terminal currently has an active text selection. + /// + /// This corresponds to the underlying terminal selection state and is + /// independent of whether the selection is currently visible in a view. + bool get hasSelection; + + /// The current mouse tracking mode requested by the terminal program. + /// + /// Programs enable DEC private modes 9, 1000, 1002, or 1003 to receive + /// mouse reports. When active, pointer events are encoded and sent to the + /// program instead of performing selection. Hold Shift to bypass tracking. + MouseTracking get mouseTracking; + + /// Sets the callback invoked when the terminal receives BEL (0x07). + /// + /// The callback runs synchronously while [write] processes the byte. Set it + /// to null to ignore BEL events. + set onBell(VoidCallback? value); + + /// Handles a clipboard write requested by terminal content. + /// + /// Requests are ignored when this is null. OSC 52 and iTerm2 Copy writes are + /// normalized into the same binary-safe request. Every content entry is a + /// representation of one logical value and must be committed atomically; no + /// entries means clear the destination, while an entry containing no bytes + /// means write an empty representation. Clipboard read requests are never + /// forwarded. + /// + /// The callback fires synchronously during [write]. Its result describes the + /// attempted write, although OSC 52 and iTerm2 Copy do not acknowledge it to + /// the terminal program. Apply an explicit trust and platform policy because + /// requests originate in untrusted terminal content. + /// + /// ```dart + /// controller.onClipboardWrite = (write) { + /// if (write.location != .standard) return .denied; + /// return appClipboard.write(write); + /// }; + /// ``` + set onClipboardWrite(ClipboardWriteCallback? callback); + + /// Sets the callback for desktop notifications requested through OSC 9 or + /// OSC 777, or clears it if null. + /// + /// Requests are untrusted. The application decides whether and how to + /// display them. Fires synchronously during [write]. + set onDesktopNotification(ValueChanged? callback); + + /// Sets the callback that sends terminal output to the backend. + /// + /// Set this before calling [write]. The callback receives bytes produced by + /// [write], [sendKey], [sendText], [paste], terminal queries, and in-band + /// resize reports. It runs synchronously on the initiating operation. When + /// invoked by [write], it is subject to the class-level non-reentrancy rule. + set onOutput(ValueChanged? value); + + /// Sets the callback for program progress reported through OSC 9;4, or + /// clears it if null. + /// + /// The application decides how to present progress. Fires synchronously + /// during [write]. + set onProgressReport(ValueChanged? callback); + + /// Sets the callback invoked when the working directory changes. + /// + /// Programs commonly report the directory with OSC 7, OSC 9, or OSC 1337. + /// Read [pwd] from the callback to obtain the updated value. The callback + /// runs synchronously while [write] processes the report. The value is kept + /// exactly as reported: OSC 7 commonly supplies a `file://` URI, whereas OSC + /// 9 and OSC 1337 commonly supply a path. + set onPwdChanged(VoidCallback? value); + + /// Sets the callback that reports measured terminal grid changes. + /// + /// Assigning this callback before a [TerminalView] has supplied measured + /// geometry does not report the controller's configured default dimensions. + /// Forward the values to your backend. If the view has already supplied + /// measured geometry, assigning the callback immediately reports that + /// committed grid. + /// + /// The callback runs after the measured geometry has been committed to the + /// terminal and input subsystems, so it may synchronously start a backend or + /// feed resulting output into this controller. Changes that affect only cell + /// pixels, padding, or device scale are committed without invoking this + /// callback. + /// + /// If DEC private mode 2048 (`TerminalMode.inBandResize`) is enabled, any + /// committed grid or cell-pixel change can first cause [onOutput] to receive + /// `CSI 48;rows;columns;pixel-height;pixel-width t`. When that update also + /// changes `cols` or `rows`, this callback runs after the report; a + /// cell-pixel-only update does not invoke it. + set onResize(OnResize? value); + + /// Sets the callback invoked when the terminal title changes. + /// + /// Programs commonly set the title with OSC 0 or OSC 2. Read [title] from + /// the callback to obtain the updated value. The callback runs synchronously + /// while [write] processes the sequence. + set onTitleChanged(VoidCallback? value); + + /// The working directory reported by the shell through OSC 7, OSC 9, or + /// OSC 1337. + /// + /// Empty when no directory has been reported or the shell cleared it. The + /// value is not parsed or normalized: it may be a `file://` URI or a path. + String get pwd; + + /// The number of scrollback rows in the active screen. + int get scrollbackRows; + + /// A snapshot of the active screen's total, visible, and offset rows. + /// + /// Scroll state does not itself notify controller listeners. Use the + /// [TerminalScrollController] supplied to [TerminalView] when a Flutter UI + /// must observe or control viewport movement. + Scrollbar get scrollbar; + + /// The title set by the running program through OSC 0 or OSC 2. + /// + /// Empty when no title has been set. + String get title; + + /// The active screen's grid rows plus scrollback rows. + int get totalRows; + + /// Virtual modifier keys for on-screen keyboard UIs. + /// + /// Merged with physical modifiers when encoding input. Cleared + /// automatically after [sendKey] or [sendText] produces output. + /// + /// ```dart + /// controller.toggleMod(const Mods.ctrl()); + /// controller.sendKey(Key.c); // Sends Ctrl+C, clears the mod. + /// ``` + Mods get virtualMods; + + /// Clears primary-screen scrollback and sends a form feed (FF, 0x0C) via + /// [onOutput]. + /// + /// This clears the selection but does not directly erase the active grid; + /// the backend decides how to handle the form feed. The entire operation is + /// a no-op on the alternate screen. + void clear(); + + /// Clears the current selection. + void clearSelection(); + + /// Clears all virtual modifiers. + void clearVirtualMods(); + + /// Creates a [Formatter] for extracting terminal content. + /// + /// The formatter reads the current active screen on every + /// [Formatter.format] call. [format] selects plain text, HTML, or VT output; + /// [unwrap] joins soft-wrapped lines; and [trim] removes trailing whitespace + /// from non-blank lines. [extra] adds terminal and screen state only to VT + /// output. + /// + /// The formatter borrows this controller's terminal. Dispose the formatter + /// before the controller, and dispose it when no longer needed. + /// + /// ```dart + /// final formatter = controller.createFormatter( + /// format: .plain, + /// unwrap: true, + /// ); + /// final snapshot = formatter.format(); + /// formatter.dispose(); + /// ``` + Formatter createFormatter({ + required FormatterFormat format, + bool unwrap = false, + bool trim = false, + FormatterExtra extra = const FormatterExtra(), + }); + + /// Returns the live value of an ANSI or DEC private terminal [mode]. + /// + /// May differ from [config] if the running program changed it through a VT + /// mode sequence. For example, query `const TerminalMode.bracketedPaste()` + /// to see whether DEC private mode 2004 is active. + bool modeGet(TerminalMode mode); + + /// Sets an ANSI or DEC private terminal [mode] at runtime. + /// + /// The change is not persisted in [config]. A program may overwrite it with + /// a VT mode sequence. When the alternate screen returns to the primary + /// screen, every mode present in [TerminalConfig.modes] is reapplied and can + /// overwrite this value. + void modeSet(TerminalMode mode, {required bool value}); + + /// Sends paste data to the terminal via [onOutput]. + /// + /// Unsafe control bytes such as NUL, ESC, and DEL are replaced with spaces. + /// When bracketed paste mode (`const TerminalMode.bracketedPaste()`) is + /// enabled, the sanitized text is wrapped in bracketed-paste delimiters; + /// otherwise newlines are converted to carriage returns. The application + /// remains responsible for confirming untrusted or multiline paste data + /// before calling this method. + /// + /// Empty text is ignored. Non-empty text scrolls to the bottom according to + /// [TerminalConfig.scrollToBottom]. + void paste(String text); + + /// Scrolls the viewport to the bottom (most recent content). + void scrollToBottom(); + + /// Scrolls the viewport to the top of the scrollback history. + void scrollToTop(); + + /// Selects all selectable content in the active screen. + /// + /// This includes scrollback while the primary screen is active. + void selectAll(); + + /// Returns the text within the current selection, or empty string when + /// there is no selection. + /// + /// [format] controls the output encoding: + /// - [FormatterFormat.plain]: unstyled text, suitable for the clipboard + /// (default). + /// - [FormatterFormat.vt]: VT escape sequences preserving colors, styles, + /// and hyperlinks. + /// - [FormatterFormat.html]: HTML with inline styles. + /// + /// In normal selection mode, soft-wrapped lines are joined into a single + /// line without an inserted newline. In block mode, every row is kept + /// separate regardless of wrapping. Trailing whitespace is preserved. + String selectedText({FormatterFormat format = .plain}); + + /// Selects the inclusive range between two terminal cells. + /// + /// Both zero-based coordinates are interpreted in [pointTag]: + /// [PointTag.active] addresses the cursor-movable grid, + /// [PointTag.viewport] the currently visible rows, [PointTag.screen] the + /// entire active screen including scrollback, and [PointTag.history] only + /// scrollback. Invalid or out-of-bounds coordinates throw. + /// + /// When [rectangle] is true, the endpoints describe opposite corners of a + /// block selection. Otherwise their direction is preserved and the range is + /// contiguous in terminal order. + /// + /// ```dart + /// controller.selectRange( + /// start: const Position(row: 0, col: 0), + /// end: const Position(row: 0, col: 4), + /// pointTag: .viewport, + /// ); + /// ``` + void selectRange({ + required Position start, + required Position end, + PointTag pointTag = .screen, + bool rectangle = false, + }); + + /// Encodes a key press according to the terminal's keyboard modes and sends + /// it via [onOutput]. + /// + /// [mods] are merged with [virtualMods]. Virtual modifiers are cleared + /// after output is produced. A key that has no representation under the + /// current modes produces no output and leaves virtual modifiers unchanged. + void sendKey(Key key, {Mods mods = const Mods.none()}); + + /// Sends literal UTF-8 text via [onOutput]. + /// + /// No key encoding, paste sanitization, or bracketed-paste wrapping is + /// applied. Use [sendKey] for individual key presses that must respect + /// terminal keyboard modes, and [paste] for clipboard content. Empty text is + /// ignored; otherwise virtual modifiers are cleared after output is sent. + void sendText(String text); + + /// Toggles a virtual modifier on or off. + void toggleMod(Mods mod); + + /// Feeds raw VT bytes from the backend into the terminal. + /// + /// Call this with data received from your PTY, SSH channel, or socket. + /// The terminal treats the stream as untrusted: malformed or unsupported + /// input is ignored or logged without corrupting state. Registered effects + /// fire synchronously, and [onOutput] can receive responses such as device + /// attributes and status reports. + /// + /// Do not call [write] from a callback fired by this method. Callback + /// exceptions are rethrown only after terminal processing completes. + void write(Uint8List data); +} diff --git a/packages/flterm/lib/src/controller/terminal_controller_impl.dart b/packages/flterm/lib/src/controller/terminal_controller_impl.dart new file mode 100644 index 00000000..0d89f8d5 --- /dev/null +++ b/packages/flterm/lib/src/controller/terminal_controller_impl.dart @@ -0,0 +1,737 @@ +part of 'terminal_controller.dart'; + +typedef _TerminalObservation = ({ + TerminalScreen activeScreen, + MouseTracking mouseTracking, + bool cursorKeyApplication, + bool cursorBlinking, +}); + +/// Owns one terminal session and its renderer-neutral behavior. +/// +/// Flutter lifecycle and device events reach this implementation only after +/// view-side adapters normalize them into terminal values. Native encoders, +/// Terminal selection resources, geometry commitment, and public callback +/// effects stay +/// within this session boundary. +final class TerminalControllerImpl extends TerminalController { + static const _cr = 0x0d; + static const _formFeed = 0x0c; + + static final _appCursorDown = Uint8List.fromList([0x1b, 0x4f, 0x42]); + static final _appCursorUp = Uint8List.fromList([0x1b, 0x4f, 0x41]); + static final _clearScrollback = utf8.encode('\x1b[3J'); + static final _crBytes = Uint8List.fromList([_cr]); + static final _cursorDown = Uint8List.fromList([0x1b, 0x5b, 0x42]); + static final _cursorUp = Uint8List.fromList([0x1b, 0x5b, 0x41]); + static final _formFeedBytes = Uint8List.fromList([_formFeed]); + + final Terminal _terminal; + final _viewportChanges = ChangeNotifier(); + late final TerminalInputEncoder _inputEncoder; + late final TerminalSelection _selection; + + ColorScheme _colorScheme = .dark; + TerminalGeometry? _committedGeometry; + TerminalConfig _config; + var _disposed = false; + late _TerminalObservation _observation; + ClipboardWriteCallback? _onClipboardWrite; + ValueChanged? _onOutput; + VoidCallback? _onPwdChanged; + OnResize? _onResize; + var _pwd = ''; + var _pwdChanged = false; + Object? _viewToken; + Mods _virtualMods = const .none(); + + TerminalControllerImpl({TerminalConfig config = const TerminalConfig()}) + : _config = config, + _terminal = Terminal(cols: config.cols, rows: config.rows), + super.base() { + _inputEncoder = TerminalInputEncoder(_terminal); + _selection = TerminalSelection(_terminal, notifyListeners); + installDefaultKittyPngDecoder(); + _wireTerminalCallbacks(); + _applyModes(); + _applyTerminalOptions(); + _observation = _readObservation(); + _terminal.addListener(_onTerminalChanged); + } + + @override + TerminalScreen get activeScreen { + _checkNotDisposed(); + return _terminal.activeScreen; + } + + Terminal get terminal { + _checkNotDisposed(); + return _terminal; + } + + bool get cursorBlinking { + _checkNotDisposed(); + return _observation.cursorBlinking; + } + + bool get isDisposed => _disposed; + + Listenable get viewportChanges { + _checkNotDisposed(); + return _viewportChanges; + } + + void setColorScheme(ColorScheme value) { + _checkNotDisposed(); + if (_colorScheme == value) return; + _colorScheme = value; + } + + @override + TerminalConfig get config { + _checkNotDisposed(); + return _config; + } + + @override + set onBell(VoidCallback? value) { + _checkNotDisposed(); + _terminal.onBell = value; + } + + @override + set onOutput(ValueChanged? value) { + _checkNotDisposed(); + _onOutput = value; + _terminal.onWritePty = value; + } + + @override + set onPwdChanged(VoidCallback? value) { + _checkNotDisposed(); + _onPwdChanged = value; + } + + @override + set onResize(OnResize? value) { + _checkNotDisposed(); + _onResize = value; + if (value == null) return; + + final geometry = _committedGeometry; + if (geometry != null) value(geometry.cols, geometry.rows); + } + + @override + set onTitleChanged(VoidCallback? value) { + _checkNotDisposed(); + _terminal.onTitleChanged = value; + } + + @override + set config(TerminalConfig value) { + _checkNotDisposed(); + if (_config == value) return; + _config = value; + _applyModes(); + _applyTerminalOptions(); + _wireTerminalCallbacks(); + _observation = _readObservation(); + notifyListeners(); + } + + @override + bool get hasSelection { + _checkNotDisposed(); + return _selection.hasSelection; + } + + @override + MouseTracking get mouseTracking { + _checkNotDisposed(); + return _observation.mouseTracking; + } + + @override + set onClipboardWrite(ClipboardWriteCallback? value) { + _checkNotDisposed(); + if (identical(_onClipboardWrite, value)) return; + _onClipboardWrite = value; + _terminal.onClipboardWrite = value; + } + + @override + set onDesktopNotification(ValueChanged? value) { + _checkNotDisposed(); + _terminal.onDesktopNotification = value; + } + + @override + set onProgressReport(ValueChanged? value) { + _checkNotDisposed(); + _terminal.onProgressReport = value; + } + + @override + String get pwd { + _checkNotDisposed(); + return _pwd; + } + + @override + int get scrollbackRows { + _checkNotDisposed(); + return _terminal.scrollbackRows; + } + + @override + Scrollbar get scrollbar { + _checkNotDisposed(); + return _terminal.scrollbar; + } + + @override + String get title { + _checkNotDisposed(); + return _terminal.title; + } + + @override + int get totalRows { + _checkNotDisposed(); + return _terminal.totalRows; + } + + @override + Mods get virtualMods { + _checkNotDisposed(); + return _virtualMods; + } + + void cancelSelectionGesture() { + _checkNotDisposed(); + _selection.cancelGesture(); + } + + Object attachView() { + _checkNotDisposed(); + if (_viewToken != null) { + throw StateError('TerminalController already has an active view.'); + } + final token = Object(); + _viewToken = token; + return token; + } + + void detachView(Object token) { + if (_disposed) return; + if (identical(_viewToken, token)) _viewToken = null; + } + + @override + void clear() { + _checkNotDisposed(); + if (_observation.activeScreen == .alternate) return; + clearSelection(); + _terminal.write(_clearScrollback); + _emitOutput(_formFeedBytes); + } + + @override + void clearSelection() { + _checkNotDisposed(); + _selection.clear(notify: true); + } + + @override + void clearVirtualMods() { + _checkNotDisposed(); + if (_virtualMods.isEmpty) return; + _virtualMods = const .none(); + notifyListeners(); + } + + @override + Formatter createFormatter({ + required FormatterFormat format, + bool unwrap = false, + bool trim = false, + FormatterExtra extra = const FormatterExtra(), + }) { + _checkNotDisposed(); + return Formatter( + terminal: _terminal, + format: format, + unwrap: unwrap, + trim: trim, + extra: extra, + ); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _viewToken = null; + _terminal.removeListener(_onTerminalChanged); + _viewportChanges.dispose(); + _inputEncoder.dispose(); + _selection.dispose(); + _terminal.dispose(); + super.dispose(); + } + + TerminalKeyDisposition handleTerminalKey( + TerminalKeyInput input, { + required bool routeToTextInput, + required bool forwardDeletionToTextInput, + }) { + _checkNotDisposed(); + if (!input.composing && + (input.action == .press || input.action == .repeat) && + input.mods.hasShift && + _terminal.selection != null) { + if (_selection.extend(input.key)) return .handled; + } + + final result = _inputEncoder.encodeKey(input); + if (result.isEmpty) return input.composing ? .handled : .ignored; + + if (routeToTextInput && result == input.character) { + _onTextInput(); + return .skipRemainingHandlers; + } + + clearVirtualMods(); + _emitOutput(utf8.encode(result)); + _onTextInput(); + + return forwardDeletionToTextInput ? .skipRemainingHandlers : .handled; + } + + void handleMouseEvent(TerminalMouseEvent event) { + _checkNotDisposed(); + final result = _inputEncoder.encodeMouse( + event, + geometry: _committedGeometry, + ); + if (result.isEmpty) return; + _emitOutput(utf8.encode(result)); + } + + void handleResize(TerminalResizeEvent event) { + _checkNotDisposed(); + final measurement = TerminalGeometry.tryFrom(event); + if (measurement == null || measurement == _committedGeometry) return; + + final previous = _committedGeometry; + _commitGeometry(measurement); + if (previous == null || + previous.cols != measurement.cols || + previous.rows != measurement.rows) { + _onResize?.call(measurement.cols, measurement.rows); + } + } + + void _commitGeometry(TerminalGeometry geometry) { + final current = _terminal.geometry; + final gridChanged = + current.cols != geometry.cols || current.rows != geometry.rows; + final pixelGeometryChanged = + current.widthPx != geometry.cols * geometry.cellWidthPx || + current.heightPx != geometry.rows * geometry.cellHeightPx; + if (gridChanged || pixelGeometryChanged) { + _terminal.resize( + cols: geometry.cols, + rows: geometry.rows, + cellWidthPx: geometry.cellWidthPx, + cellHeightPx: geometry.cellHeightPx, + ); + } + + _inputEncoder.updateGeometry(geometry); + _selection.updateGeometry(geometry); + + _committedGeometry = geometry; + } + + void handleTerminalScroll(TerminalScrollEvent event) { + _checkNotDisposed(); + if (event.horizontal == 0 && event.vertical == 0) return; + + if (event.reportMouse) { + if (_terminal.mouseTracking == .none) return; + final position = (x: event.pixelX, y: event.pixelY); + _sendScrollButtons( + event.vertical, + negativeButton: .four, + positiveButton: .five, + position: position, + mods: event.mods, + ); + _sendScrollButtons( + event.horizontal, + negativeButton: .six, + positiveButton: .seven, + position: position, + mods: event.mods, + ); + return; + } + + if (_terminal.mouseTracking != .none || + _terminal.activeScreen != .alternate || + !_terminal.modeGet(const .alternateScroll()) || + event.vertical == 0) { + return; + } + + final up = _observation.cursorKeyApplication ? _appCursorUp : _cursorUp; + final down = _observation.cursorKeyApplication + ? _appCursorDown + : _cursorDown; + final key = event.vertical < 0 ? up : down; + final count = event.vertical.abs(); + _emitOutput(_repeatBytes(key, count)); + } + + void handleSelectionPress(TerminalSelectionPressEvent event) { + _checkNotDisposed(); + _selection.handlePress(event); + } + + void handleSelectionRelease(Position cell) { + _checkNotDisposed(); + _selection.handleRelease(cell); + } + + void invalidateSelection() { + _checkNotDisposed(); + _selection.invalidate(); + } + + @override + bool modeGet(TerminalMode mode) { + _checkNotDisposed(); + return _terminal.modeGet(mode); + } + + @override + void modeSet(TerminalMode mode, {required bool value}) { + _checkNotDisposed(); + _terminal.modeSet(mode, value: value); + } + + @override + void paste(String text) { + _checkNotDisposed(); + if (text.isEmpty) return; + final bracketed = _terminal.modeGet(const .bracketedPaste()); + _emitOutput(pasteEncode(text, bracketed: bracketed)); + _scrollToBottomOnInput(); + } + + @override + void scrollToBottom() { + _checkNotDisposed(); + if (_observation.activeScreen == .alternate) return; + final previousOffset = _terminal.scrollbar.offset; + _terminal.scrollToBottom(); + if (_terminal.scrollbar.offset != previousOffset) { + _viewportChanges.notifyListeners(); + } + } + + void scrollToRow(int row) { + _checkNotDisposed(); + final previousOffset = _terminal.scrollbar.offset; + _terminal.scrollToRow(row); + if (_terminal.scrollbar.offset != previousOffset) { + _viewportChanges.notifyListeners(); + } + } + + @override + void scrollToTop() { + _checkNotDisposed(); + if (_observation.activeScreen == .alternate) return; + final previousOffset = _terminal.scrollbar.offset; + _terminal.scrollToTop(); + if (_terminal.scrollbar.offset != previousOffset) { + _viewportChanges.notifyListeners(); + } + } + + @override + void selectAll() { + _checkNotDisposed(); + _selection.selectAll(); + } + + @override + String selectedText({FormatterFormat format = .plain}) { + _checkNotDisposed(); + return _selection.selectedText(format: format); + } + + @override + void selectRange({ + required Position start, + required Position end, + PointTag pointTag = .screen, + bool rectangle = false, + }) { + _checkNotDisposed(); + _selection.selectRange( + start: start, + end: end, + pointTag: pointTag, + rectangle: rectangle, + ); + } + + @override + void sendKey(Key key, {Mods mods = const .none()}) { + _checkNotDisposed(); + final effectiveMods = mods | _virtualMods; + final result = _inputEncoder.encodeKeyPress(key, mods: effectiveMods); + if (result.isEmpty) return; + _emitOutput(utf8.encode(result)); + clearVirtualMods(); + } + + @override + void sendText(String text) { + _checkNotDisposed(); + if (text.isEmpty) return; + _emitOutput(utf8.encode(text)); + clearVirtualMods(); + } + + @override + void toggleMod(Mods mod) { + _checkNotDisposed(); + _virtualMods = _virtualMods ^ mod; + notifyListeners(); + } + + void updateSelectionAutoscroll(TerminalSelectionAutoscrollEvent event) { + _checkNotDisposed(); + _selection.handleAutoscroll(event); + } + + void updateSelectionDrag(TerminalSelectionDragEvent event) { + _checkNotDisposed(); + _selection.handleDrag(event); + } + + @override + void write(Uint8List data) { + _checkNotDisposed(); + _terminal.write(data); + _scrollToBottomOnOutput(); + } + + void _applyModes() { + _checkNotDisposed(); + for (final entry in _config.modes.entries) { + _terminal.modeSet(entry.key, value: entry.value); + } + } + + void _applyTerminalOptions() { + _terminal.scrollbackMaxBytes = _config.scrollbackMaxBytes; + _terminal.scrollbackMaxLines = _config.scrollbackMaxLines; + _terminal.kittyImageStorageLimit = _config.kittyImageStorageLimit; + _terminal.setApcBufferLimit(_config.apcBufferLimit); + _terminal.setGlyphProtocol(enabled: _config.glyphProtocol); + _terminal.defaultCursorShape = _config.cursorStyle; + _terminal.defaultCursorBlink = _config.cursorBlink; + } + + bool _effectiveCursorBlinking() { + return _config.cursorBlink ?? _terminal.modeGet(const .cursorBlinking()); + } + + bool _emitKeyPress( + Key key, { + Mods mods = const .none(), + bool clearMods = true, + }) { + final result = _inputEncoder.encodeKeyPress(key, mods: mods); + if (result.isEmpty) return false; + + _emitOutput(utf8.encode(result)); + if (clearMods) clearVirtualMods(); + return true; + } + + void _emitOutput(Uint8List bytes) => _onOutput?.call(bytes); + + void _sendScrollButtons( + int steps, { + required MouseButton negativeButton, + required MouseButton positiveButton, + required ({double x, double y}) position, + required Mods mods, + }) { + if (steps == 0) return; + final button = steps < 0 ? negativeButton : positiveButton; + final result = _inputEncoder.encodeScrollButton( + button: button, + pixelX: position.x, + pixelY: position.y, + mods: mods, + geometry: _committedGeometry, + ); + if (result.isEmpty) return; + _emitOutput(_repeatBytes(utf8.encode(result), steps.abs())); + } + + Uint8List _repeatBytes(List value, int count) { + final bytes = Uint8List(value.length * count); + for (var i = 0; i < count; i++) { + bytes.setRange(i * value.length, (i + 1) * value.length, value); + } + return bytes; + } + + void handleTextDeleted(int count) { + _checkNotDisposed(); + if (count <= 0) return; + + var emitted = false; + for (var i = 0; i < count; i++) { + emitted = + _emitKeyPress(.backspace, mods: _virtualMods, clearMods: false) || + emitted; + } + if (!emitted) return; + + clearVirtualMods(); + _onTextInput(); + } + + void handleTextNewline() { + _checkNotDisposed(); + _emitOutput(_crBytes); + clearVirtualMods(); + _onTextInput(); + } + + TerminalSizeInfo _handleSizeQuery() { + _checkNotDisposed(); + final geometry = _terminal.geometry; + final committed = _committedGeometry; + final cellWidth = geometry.cols > 0 && geometry.widthPx > 0 + ? geometry.widthPx ~/ geometry.cols + : committed?.cellWidthPx ?? 0; + final cellHeight = geometry.rows > 0 && geometry.heightPx > 0 + ? geometry.heightPx ~/ geometry.rows + : committed?.cellHeightPx ?? 0; + return TerminalSizeInfo( + rows: geometry.rows, + columns: geometry.cols, + cellWidth: cellWidth, + cellHeight: cellHeight, + ); + } + + void handleTextCommitted(String text) { + _checkNotDisposed(); + if (_virtualMods.isEmpty) { + _emitOutput(utf8.encode(text)); + _onTextInput(); + return; + } + + if (text.length == 1) { + final key = keyFromCodepoint(text.codeUnitAt(0)); + if (key != null) { + sendKey(key); + return; + } + } + + _emitOutput(utf8.encode(text)); + clearVirtualMods(); + _onTextInput(); + } + + void handleTextCompositionChanged({required bool active}) { + _checkNotDisposed(); + if (active) _onTextInput(); + } + + void handleFocusChanged({required bool focused}) { + _checkNotDisposed(); + if (!focused) clearVirtualMods(); + + if (_terminal.modeGet(const TerminalMode.focusEvent())) { + final event = focused ? FocusEvent.gained : FocusEvent.lost; + _emitOutput(utf8.encode(event.encode())); + } + } + + void _onTerminalChanged() { + if (_disposed) return; + final pwdChanged = _pwdChanged; + _pwdChanged = false; + final previous = _observation; + final next = _readObservation(); + _observation = next; + if (previous.activeScreen != next.activeScreen && + next.activeScreen == .primary) { + _applyModes(); + } + + if (pwdChanged || previous != next) notifyListeners(); + } + + void _handlePwdChanged() { + // The terminal listener publishes the final state after the write ends. + _pwd = _terminal.pwd; + _pwdChanged = true; + _onPwdChanged?.call(); + } + + void _onTextInput() { + if (_config.selectionClearOnTyping) clearSelection(); + _scrollToBottomOnInput(); + } + + void _scrollToBottomOnInput() { + if (_observation.activeScreen == .alternate) return; + final policy = _config.scrollToBottom; + if (policy == .onKeystroke || policy == .both) scrollToBottom(); + } + + void _scrollToBottomOnOutput() { + if (_observation.activeScreen == .alternate) return; + final policy = _config.scrollToBottom; + if (policy == .onOutput || policy == .both) scrollToBottom(); + } + + void _wireTerminalCallbacks() { + _terminal.onColorScheme = () => _colorScheme; + _terminal.onSize = _handleSizeQuery; + _terminal.onPwdChanged = _handlePwdChanged; + _terminal.onDeviceAttributes = () => _config.deviceAttributes; + final enquiry = _config.enquiryResponse; + _terminal.onEnquiry = enquiry.isEmpty + ? null + : () => .fromList(utf8.encode(enquiry)); + } + + _TerminalObservation _readObservation() => ( + activeScreen: _terminal.activeScreen, + mouseTracking: _terminal.mouseTracking, + cursorKeyApplication: _terminal.modeGet(const .cursorKeys()), + cursorBlinking: _effectiveCursorBlinking(), + ); + + void _checkNotDisposed() { + if (_disposed) throw StateError('TerminalController is disposed.'); + } +} diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index 85ba6efa..35589985 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -1,4 +1,3 @@ -export 'foundation/callbacks.dart'; export 'foundation/cell_metrics.dart'; export 'foundation/cell_range.dart'; export 'foundation/color_palette.dart'; @@ -6,6 +5,6 @@ export 'foundation/dynamic_color.dart'; export 'foundation/input_types.dart'; export 'foundation/platform_map.dart'; export 'foundation/terminal_config.dart'; +export 'foundation/terminal_geometry.dart'; export 'foundation/terminal_gesture_settings.dart'; -export 'foundation/terminal_render_observer.dart'; export 'foundation/terminal_theme.dart'; diff --git a/packages/flterm/lib/src/foundation/callbacks.dart b/packages/flterm/lib/src/foundation/callbacks.dart deleted file mode 100644 index af7bcd4d..00000000 --- a/packages/flterm/lib/src/foundation/callbacks.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:libghostty/libghostty.dart'; - -/// Callback for terminal grid resize events. -/// -/// Fires when the [TerminalView] layout changes and produces a different -/// number of character [cols] and [rows]. Set on [TerminalController.onResize] -/// to forward size changes to the backend (PTY, SSH, etc.). -/// -/// ```dart -/// controller.onResize = (cols, rows) => pty.resize(cols, rows); -/// ``` -typedef OnResize = void Function(int cols, int rows); - -/// Mouse event data from the gesture detector to the controller. -/// -/// Carries the raw pixel coordinates and the semantic action/button so -/// the controller can encode mouse reports for the terminal. Pixel -/// coordinates are relative to the terminal grid origin (after padding). -/// -/// ```dart -/// final event = ( -/// action: MouseAction.press, -/// button: MouseButton.left, -/// pixelX: offset.dx, -/// pixelY: offset.dy, -/// ); -/// controller.handleMouseEvent(event); -/// ``` -typedef TerminalMouseEvent = ({ - MouseAction action, - MouseButton button, - double pixelX, - double pixelY, -}); diff --git a/packages/flterm/lib/src/foundation/input_types.dart b/packages/flterm/lib/src/foundation/input_types.dart index 9674e030..aac2026e 100644 --- a/packages/flterm/lib/src/foundation/input_types.dart +++ b/packages/flterm/lib/src/foundation/input_types.dart @@ -1,25 +1,3 @@ -/// Soft keyboard visibility state on mobile platforms. -/// -/// Managed by [TerminalController] and driven by focus events and explicit -/// API calls. On desktop platforms where a physical keyboard is always -/// present, this state has no visible effect. -/// -/// ```dart -/// if (controller.keyboardState == KeyboardState.disabled) { -/// controller.showKeyboard(); -/// } -/// ``` -enum KeyboardState { - /// Keyboard visible, text input active. - showing, - - /// Keyboard hidden, re-shows on next focus gain. - hidden, - - /// Keyboard hidden, stays hidden until [TerminalController.showKeyboard]. - disabled, -} - /// Controls when the mouse cursor hides during terminal interaction. /// /// Passed to [TerminalView.mouseAutoHide] to configure cursor visibility diff --git a/packages/flterm/lib/src/foundation/terminal_config.dart b/packages/flterm/lib/src/foundation/terminal_config.dart index 547d19d3..d3cf8dec 100644 --- a/packages/flterm/lib/src/foundation/terminal_config.dart +++ b/packages/flterm/lib/src/foundation/terminal_config.dart @@ -23,9 +23,11 @@ enum ScrollToBottom { /// /// Immutable value object passed to [TerminalController] at creation or /// replaced at runtime via [TerminalController.config]. Replacing the -/// config applies mode changes and updates encoders without recreating -/// the terminal: scrollback, screen content, and cursor position are -/// preserved. +/// config applies its modes, limits, cursor defaults, query responses, and +/// input policies without recreating the terminal. [cols] and [rows] apply +/// only when the controller is created; measured [TerminalView] geometry owns +/// the live grid size. Lower resource limits can prune scrollback or protocol +/// data. /// /// All defaults produce standard terminal behavior out of the box. /// @@ -73,31 +75,41 @@ class TerminalConfig { /// Default APC payload buffer limit. static const defaultApcBufferLimit = 65 * 1024 * 1024; - /// Initial terminal width in cells. Must be positive. + /// Initial terminal width in cells. + /// + /// Must be positive. Replacing [TerminalController.config] does not resize an + /// existing terminal; [TerminalView] supplies its measured live dimensions. final int cols; - /// Initial terminal height in cells. Must be positive. + /// Initial terminal height in cells. + /// + /// Must be positive. Replacing [TerminalController.config] does not resize an + /// existing terminal; [TerminalView] supplies its measured live dimensions. final int rows; /// Maximum scrollback buffer size in bytes. /// /// Defaults to 10,000 bytes. Set to null for no limit, or 0 to disable - /// scrollback. When this and [scrollbackMaxLines] are set, the oldest - /// complete pages are discarded when either limit is reached. + /// scrollback and erase retained history. The limit is approximate because + /// libghostty allocates and prunes complete pages. When this and + /// [scrollbackMaxLines] are set, the oldest eligible pages are discarded + /// when either limit is reached. Lowering the value can prune immediately. final int? scrollbackMaxBytes; /// Maximum number of physical scrollback rows. /// - /// Defaults to no limit. At least one normal page is retained. When this - /// and [scrollbackMaxBytes] are set, the oldest complete pages are - /// discarded when either limit is reached. + /// Defaults to no limit. The limit is approximate because libghostty + /// allocates and prunes complete pages, so the retained count is generally + /// somewhat higher. When this and [scrollbackMaxBytes] are set, the oldest + /// eligible pages are discarded when either limit is reached. Lowering the + /// value can prune immediately. final int? scrollbackMaxLines; /// Maximum bytes of Kitty graphics image storage. /// /// Caps the in-memory footprint of images transmitted via the Kitty /// graphics protocol. Defaults to 64 MiB. Set to 0 to reject every - /// image payload. + /// image payload and delete stored Kitty images and placements. final int kittyImageStorageLimit; /// Maximum bytes buffered for APC payloads. @@ -125,6 +137,7 @@ class TerminalConfig { /// Terminal modes applied on init and primary screen restore. /// + /// Every map entry is written; omitting a mode does not reset its live value. /// Programs can change modes at runtime via escape sequences. Use /// [TerminalController.modeGet] and [TerminalController.modeSet] to /// query or override the live state. diff --git a/packages/flterm/lib/src/foundation/terminal_geometry.dart b/packages/flterm/lib/src/foundation/terminal_geometry.dart new file mode 100644 index 00000000..2e56d48b --- /dev/null +++ b/packages/flterm/lib/src/foundation/terminal_geometry.dart @@ -0,0 +1,235 @@ +import 'package:meta/meta.dart'; + +/// A validated, immutable measurement of the terminal surface. +/// +/// Logical values describe the Flutter view. Physical values describe the +/// surface supplied to the terminal engine and the mouse encoder. Construction +/// rejects non-finite, empty, or protocol-unrepresentable measurements; callers +/// can therefore commit every non-null instance without repeating validation. +@immutable +@internal +final class TerminalGeometry { + /// Maximum grid dimension representable by the native terminal geometry. + static const _maxGridDimension = 0xffff; + + /// Maximum physical dimension representable by mouse-coordinate encoding. + static const _maxMouseDimension = 0xffffffff; + + final int cols; + final int rows; + final double cellWidth; + final double cellHeight; + final double paddingLeft; + final double paddingRight; + final double paddingTop; + final double paddingBottom; + final double devicePixelRatio; + final int cellWidthPx; + final int cellHeightPx; + final int paddingLeftPx; + final int paddingRightPx; + final int paddingTopPx; + final int paddingBottomPx; + final int screenWidth; + final int screenHeight; + + const TerminalGeometry._({ + required this.cols, + required this.rows, + required this.cellWidth, + required this.cellHeight, + required this.paddingLeft, + required this.paddingRight, + required this.paddingTop, + required this.paddingBottom, + required this.devicePixelRatio, + required this.cellWidthPx, + required this.cellHeightPx, + required this.paddingLeftPx, + required this.paddingRightPx, + required this.paddingTopPx, + required this.paddingBottomPx, + required this.screenWidth, + required this.screenHeight, + }); + + @override + int get hashCode => Object.hash( + cols, + rows, + cellWidth, + cellHeight, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + devicePixelRatio, + ); + + @override + bool operator ==(Object other) { + return other is TerminalGeometry && + other.cols == cols && + other.rows == rows && + other.cellWidth == cellWidth && + other.cellHeight == cellHeight && + other.paddingLeft == paddingLeft && + other.paddingRight == paddingRight && + other.paddingTop == paddingTop && + other.paddingBottom == paddingBottom && + other.devicePixelRatio == devicePixelRatio; + } + + /// Creates a validated measurement from a view resize event. + static TerminalGeometry? tryFrom(TerminalResizeEvent event) { + if (event.cols <= 0 || + event.cols > _maxGridDimension || + event.rows <= 0 || + event.rows > _maxGridDimension || + !_isPositive(event.cellWidth) || + !_isPositive(event.cellHeight) || + !_isNonNegative(event.paddingLeft) || + !_isNonNegative(event.paddingRight) || + !_isNonNegative(event.paddingTop) || + !_isNonNegative(event.paddingBottom) || + !_isPositive(event.devicePixelRatio)) { + return null; + } + + final dpr = event.devicePixelRatio; + final cellWidthPx = _physicalPixels(event.cellWidth, dpr, nonZero: true); + final cellHeightPx = _physicalPixels(event.cellHeight, dpr, nonZero: true); + final paddingLeftPx = _physicalPixels(event.paddingLeft, dpr); + final paddingRightPx = _physicalPixels(event.paddingRight, dpr); + final paddingTopPx = _physicalPixels(event.paddingTop, dpr); + final paddingBottomPx = _physicalPixels(event.paddingBottom, dpr); + if (cellWidthPx == null || + cellHeightPx == null || + paddingLeftPx == null || + paddingRightPx == null || + paddingTopPx == null || + paddingBottomPx == null) { + return null; + } + + final screenWidth = + event.cols * cellWidthPx + paddingLeftPx + paddingRightPx; + final screenHeight = + event.rows * cellHeightPx + paddingTopPx + paddingBottomPx; + if (screenWidth > _maxMouseDimension || screenHeight > _maxMouseDimension) { + return null; + } + + return TerminalGeometry._( + cols: event.cols, + rows: event.rows, + cellWidth: event.cellWidth, + cellHeight: event.cellHeight, + paddingLeft: event.paddingLeft, + paddingRight: event.paddingRight, + paddingTop: event.paddingTop, + paddingBottom: event.paddingBottom, + devicePixelRatio: event.devicePixelRatio, + cellWidthPx: cellWidthPx, + cellHeightPx: cellHeightPx, + paddingLeftPx: paddingLeftPx, + paddingRightPx: paddingRightPx, + paddingTopPx: paddingTopPx, + paddingBottomPx: paddingBottomPx, + screenWidth: screenWidth, + screenHeight: screenHeight, + ); + } + + static bool _isNonNegative(double value) => value.isFinite && value >= 0; + + static bool _isPositive(double value) => value.isFinite && value > 0; + + static int? _physicalPixels( + double logicalPixels, + double devicePixelRatio, { + bool nonZero = false, + }) { + final physicalPixels = logicalPixels * devicePixelRatio; + if (!physicalPixels.isFinite || physicalPixels > _maxMouseDimension) { + return null; + } + final pixels = physicalPixels.round(); + return nonZero && pixels == 0 ? null : pixels; + } +} + +/// A complete terminal surface measurement in logical pixels. +/// +/// The renderer produces this value; the controller validates and commits it +/// before input and selection consume the resulting [TerminalGeometry]. One +/// event contains the grid, cell metrics, surface padding, and device scale so +/// no consumer can observe a partially updated measurement. +@immutable +final class TerminalResizeEvent { + /// Number of terminal columns. + final int cols; + + /// Number of terminal rows. + final int rows; + + /// Cell width in logical pixels. + final double cellWidth; + + /// Cell height in logical pixels. + final double cellHeight; + + /// Logical padding on the left side of the terminal surface. + final double paddingLeft; + + /// Logical padding on the right side of the terminal surface. + final double paddingRight; + + /// Logical padding on the top side of the terminal surface. + final double paddingTop; + + /// Logical padding on the bottom side of the terminal surface. + final double paddingBottom; + + /// Device-pixel ratio of the hosting Flutter view. + final double devicePixelRatio; + + const TerminalResizeEvent({ + required this.cols, + required this.rows, + required this.cellWidth, + required this.cellHeight, + required this.paddingLeft, + required this.paddingRight, + required this.paddingTop, + required this.paddingBottom, + required this.devicePixelRatio, + }); + + @override + int get hashCode => Object.hash( + cols, + rows, + cellWidth, + cellHeight, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + devicePixelRatio, + ); + + @override + bool operator ==(Object other) { + return other is TerminalResizeEvent && + other.cols == cols && + other.rows == rows && + other.cellWidth == cellWidth && + other.cellHeight == cellHeight && + other.paddingLeft == paddingLeft && + other.paddingRight == paddingRight && + other.paddingTop == paddingTop && + other.paddingBottom == paddingBottom && + other.devicePixelRatio == devicePixelRatio; + } +} diff --git a/packages/flterm/lib/src/foundation/terminal_render_observer.dart b/packages/flterm/lib/src/foundation/terminal_render_observer.dart deleted file mode 100644 index 9c406836..00000000 --- a/packages/flterm/lib/src/foundation/terminal_render_observer.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/foundation.dart'; - -/// Observable focus state for the rendering layer. -/// -/// Implemented by [TerminalController] and consumed by painters that need -/// to react to focus changes or selection updates without depending on -/// the full controller API. -/// -/// Listeners are notified when [hasFocus] changes, triggering repaint of -/// cursor state. -abstract class TerminalRenderObserver implements Listenable { - /// Whether the terminal view has keyboard focus. - /// - /// Painters use this to adjust cursor rendering: a focused terminal - /// draws a filled cursor, while an unfocused terminal draws a hollow - /// block outline. - bool get hasFocus; -} diff --git a/packages/flterm/lib/src/input/terminal_gesture_detector.dart b/packages/flterm/lib/src/input/terminal_gesture_detector.dart new file mode 100644 index 00000000..5ce654bc --- /dev/null +++ b/packages/flterm/lib/src/input/terminal_gesture_detector.dart @@ -0,0 +1,712 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:libghostty/libghostty.dart' + show MouseAction, MouseButton, Position; +import 'package:meta/meta.dart'; + +import '../foundation.dart'; +import '../interaction/terminal_selection.dart'; +import '../links/link_interaction.dart'; +import '../links/link_settings.dart'; +import '../view/terminal_view_attachment.dart'; +import 'terminal_input_event.dart'; +import 'terminal_raw_gesture_detector.dart'; +import 'terminal_scroll_gesture_handler.dart'; + +/// Owns pointer-sequence arbitration for one terminal view. +/// +/// It keeps mouse reporting, selection, link activation, and cancellation on +/// the same pointer identity. Terminal-directed wheel, touch, and trackpad +/// motion is delegated to [TerminalScrollGestureHandler]. All resulting +/// terminal actions cross [TerminalViewAttachment] as normalized values. +/// +/// Pointer ownership is decided once per sequence. Modifier changes may alter +/// the shape of an active selection, but they do not transfer the sequence to +/// link activation or terminal mouse reporting. Cancellation releases every +/// owned interaction before another pointer can claim it. +@internal +final class TerminalGestureDetector extends StatefulWidget { + final Widget child; + final CellMetrics metrics; + final LinkInteraction links; + final ScrollPhysics scrollPhysics; + final TerminalGestureSettings settings; + final TerminalViewAttachment attachment; + final ScrollController? scrollController; + final TerminalInteractionState interaction; + final ValueChanged? onLinkActivate; + + const TerminalGestureDetector({ + super.key, + required this.child, + required this.links, + required this.metrics, + required this.attachment, + required this.interaction, + this.onLinkActivate, + this.scrollController, + this.settings = const TerminalGestureSettings(), + this.scrollPhysics = const ClampingScrollPhysics(), + }); + + @override + State createState() => + _TerminalGestureDetectorState(); +} + +final class _TerminalGestureDetectorState + extends State { + static const _mouseButtons = { + kPrimaryMouseButton: .left, + kMiddleMouseButton: .middle, + kSecondaryMouseButton: .right, + kBackMouseButton: .eight, + kForwardMouseButton: .nine, + }; + static const _supportedMouseButtons = + kPrimaryMouseButton | + kMiddleMouseButton | + kSecondaryMouseButton | + kBackMouseButton | + kForwardMouseButton; + + final _activePointers = {}; + Timer? _autoScrollTimer; + _DragState? _drag; + int? _interactionPointer; + Duration? _interactionTimeStamp; + var _linkPressActive = false; + Position? _pressCell; + var _terminalDragActive = false; + var _terminalOwnsInteraction = false; + + TerminalViewAttachment get _attachment => widget.attachment; + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _handleTrackedDown, + onPointerMove: _handleTrackedMove, + onPointerHover: _handleTrackedHover, + onPointerUp: _handleTrackedUp, + onPointerCancel: _handleTrackedCancel, + child: TerminalScrollGestureHandler( + metrics: widget.metrics, + attachment: widget.attachment, + physics: widget.scrollPhysics, + interaction: widget.interaction, + onScrollStart: _handleScrollStart, + child: TerminalRawGestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onDragStart: _handleDragStart, + onDragUpdate: _handleDragUpdate, + onDragEnd: _endDrag, + onLongPressStart: _handleLongPressStart, + onLongPressMoveUpdate: _handleLongPressMoveUpdate, + onLongPressUp: _endDrag, + child: widget.child, + ), + ), + ); + } + + @override + void didUpdateWidget(TerminalGestureDetector oldWidget) { + super.didUpdateWidget(oldWidget); + final attachmentChanged = widget.attachment != oldWidget.attachment; + if (attachmentChanged) { + _interactionPointer = null; + _interactionTimeStamp = null; + _terminalDragActive = false; + _terminalOwnsInteraction = false; + } + if (widget.links != oldWidget.links && _linkPressActive) { + _linkPressActive = false; + oldWidget.links.cancel(); + } + if (widget.metrics != oldWidget.metrics || attachmentChanged) { + final attachment = attachmentChanged + ? oldWidget.attachment + : widget.attachment; + _cancelSelectionInteraction(attachment); + if (!attachmentChanged) attachment.invalidateSelection(); + } + if (attachmentChanged) _releaseTrackedPointers(oldWidget.attachment); + } + + @override + void dispose() { + _cancelSelectionInteraction(widget.attachment); + _releaseTrackedPointers(widget.attachment); + super.dispose(); + } + + void _autoScrollTick(Timer timer) { + final scrollController = widget.scrollController; + if (scrollController == null || !scrollController.hasClients) { + _stopAutoScroll(); + return; + } + + final drag = _drag; + if (drag == null) { + _stopAutoScroll(); + return; + } + + _attachment.updateSelectionAutoscroll( + TerminalSelectionAutoscrollEvent( + cell: drag.cell, + pixelX: drag.localPosition.dx, + pixelY: drag.localPosition.dy, + rectangle: drag.lastRectangle, + ), + ); + } + + MouseButton? _buttonForDownEvent(PointerDownEvent event) { + return switch (event.kind) { + .touch => .left, + .mouse => _mouseButtonForBit( + smallestButton(event.buttons & _supportedMouseButtons), + ), + .stylus || .invertedStylus => _stylusButtonForMask(event.buttons), + _ => null, + }; + } + + void _cancelLinkPress() { + if (!_linkPressActive) return; + _linkPressActive = false; + widget.links.cancel(); + } + + void _cancelSelectionInteraction( + TerminalViewAttachment attachment, { + bool clearSelection = false, + }) { + if (clearSelection || _drag != null || _pressCell != null) { + attachment.cancelSelectionGesture(); + } + _cancelLinkPress(); + _clearDrag(); + _pressCell = null; + } + + void _cancelSelectionPress() { + if (_pressCell == null) return; + _attachment.cancelSelectionGesture(); + _pressCell = null; + } + + void _clearDrag() { + if (_drag == null) return; + HardwareKeyboard.instance.removeHandler(_handleModifierKey); + _stopAutoScroll(); + _drag = null; + } + + void _endDrag() { + if (_terminalDragActive) { + _terminalDragActive = false; + _terminalOwnsInteraction = false; + return; + } + final drag = _drag; + if (drag == null) return; + _releaseSelectionPress(drag.cell); + _clearDrag(); + _cancelLinkPress(); + _terminalOwnsInteraction = false; + } + + void _handleDragStart(DragStartDetails details) { + _attachment.requestFocus(); + _cancelLinkPress(); + if (_terminalOwnsInteraction) { + _terminalDragActive = true; + return; + } + if (!widget.settings.dragSelection) { + _cancelSelectionPress(); + return; + } + + _startDrag( + details.localPosition, + beginPress: _pressCell == null, + timeStamp: details.sourceTimeStamp ?? _interactionTimeStamp, + ); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (_drag != null) _updateDrag(details.localPosition); + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + if (_drag != null) _updateDrag(details.localPosition); + } + + void _handleLongPressStart(LongPressStartDetails details) { + _attachment.requestFocus(); + if (_terminalOwnsInteraction) return; + if (!widget.settings.longPressSelection) { + _cancelSelectionPress(); + return; + } + _startDrag( + details.localPosition, + rectangle: widget.settings.longPressSelectionShape == .rectangle, + beginPress: _pressCell == null, + timeStamp: _interactionTimeStamp, + ); + } + + bool _handleModifierKey(KeyEvent _) { + final drag = _drag; + if (drag != null) _updateDrag(drag.localPosition); + return false; + } + + void _handleScrollStart(PointerDeviceKind kind) { + _cancelSelectionInteraction(_attachment, clearSelection: true); + if (kind == .touch) { + _attachment.requestFocus(); + _activePointers.removeWhere((_, pointer) => pointer.kind == .touch); + } + } + + void _handleSelectionPress(Offset position, Duration? timeStamp) { + final settings = widget.settings; + final cell = widget.metrics.cellAt(position); + _attachment.handleSelectionPress( + TerminalSelectionPressEvent( + cell: cell, + pixelX: position.dx, + pixelY: position.dy, + behaviors: settings.selectionBehaviors, + wordBoundaries: settings.wordBoundaries, + repeatDistance: kDoubleTapSlop, + repeatInterval: kDoubleTapTimeout, + timeStamp: timeStamp ?? Duration.zero, + fullWidthLine: settings.lineSelectMode == .full, + ), + ); + _pressCell = cell; + } + + void _handleTapDown(TapDownDetails details, Duration timeStamp) { + _attachment.requestFocus(); + if (_terminalOwnsInteraction) return; + if (widget.links.handlePress( + localPosition: details.localPosition, + metrics: widget.metrics, + pointerKind: details.kind ?? .mouse, + virtualMods: _attachment.virtualMods, + )) { + _linkPressActive = true; + _cancelSelectionPress(); + return; + } + _handleSelectionPress(details.localPosition, timeStamp); + } + + void _handleTapUp(TapUpDetails details) { + if (_linkPressActive) { + _linkPressActive = false; + final link = widget.links.handleRelease( + localPosition: details.localPosition, + metrics: widget.metrics, + ); + if (link != null) widget.onLinkActivate?.call(link); + _terminalOwnsInteraction = false; + return; + } + if (_terminalOwnsInteraction) { + _terminalOwnsInteraction = false; + return; + } + if (_pressCell == null && _isMouseTracked()) { + return; + } + _releaseSelectionPress(widget.metrics.cellAt(details.localPosition)); + } + + void _handleTrackedCancel(PointerCancelEvent event) { + final pointer = _activePointers[event.pointer]; + if (pointer != null && pointer.kind != .touch) { + _releaseTrackedPointer(event.pointer, event.localPosition); + } else { + _activePointers.remove(event.pointer); + } + if (_interactionPointer != event.pointer) return; + _interactionPointer = null; + _interactionTimeStamp = null; + _terminalDragActive = false; + _terminalOwnsInteraction = false; + _cancelSelectionPress(); + _cancelLinkPress(); + _clearDrag(); + } + + void _handleTrackedDown(PointerDownEvent event) { + if (_activePointers.containsKey(event.pointer)) return; + final tracked = _isMouseTracked(); + final button = tracked ? _buttonForDownEvent(event) : null; + if (_interactionPointer == null) { + _interactionPointer = event.pointer; + _interactionTimeStamp = event.timeStamp; + _terminalOwnsInteraction = button != null; + } + if (!tracked) return; + if (button == null) return; + if (event.kind == .touch && _interactionPointer != event.pointer) return; + + final pointer = _TrackedPointer( + button: button, + buttons: 0, + kind: event.kind, + position: event.localPosition, + tapCandidate: event.kind == .touch, + ); + _activePointers[event.pointer] = pointer; + if (event.kind == .mouse) { + _updateMouseButtons(pointer, event.buttons, event.localPosition); + return; + } + if (event.kind == .stylus || event.kind == .invertedStylus) { + _updateStylusButton(pointer, event.buttons, event.localPosition); + } + } + + void _handleTrackedHover(PointerHoverEvent event) { + if (!_isMouseTracked()) return; + if (!_isHoverKind(event.kind)) return; + _sendMouseEvent(.motion, event.localPosition); + } + + void _handleTrackedMove(PointerMoveEvent event) { + final pointer = _activePointers[event.pointer]; + if (pointer == null) return; + if (pointer.tapCandidate && + (event.localPosition - pointer.downPosition).distance > kTouchSlop) { + pointer.tapCandidate = false; + } + final moved = pointer.position != event.localPosition; + if (pointer.kind == .mouse) { + _updateMouseButtons(pointer, event.buttons, event.localPosition); + if (!moved) return; + } else if (pointer.kind == .stylus || pointer.kind == .invertedStylus) { + _updateStylusButton(pointer, event.buttons, event.localPosition); + if (!moved) return; + } else { + pointer.position = event.localPosition; + } + if (pointer.kind == .touch) return; + _sendMouseEvent( + .motion, + event.localPosition, + button: pointer.buttons == 0 ? null : pointer.button, + ); + } + + void _handleTrackedUp(PointerUpEvent event) { + _releaseTrackedPointer(event.pointer, event.localPosition); + if (_interactionPointer == event.pointer) { + _interactionPointer = null; + _interactionTimeStamp = null; + } + } + + bool _isBlockModifierPressed() { + final modifier = widget.settings.blockSelectionModifier; + if (modifier == null) return false; + final keyboard = HardwareKeyboard.instance; + final mods = _attachment.virtualMods; + return switch (modifier) { + .alt => keyboard.isAltPressed || mods.hasAlt, + .meta => keyboard.isMetaPressed || mods.hasSuper, + .shift => keyboard.isShiftPressed || mods.hasShift, + .control => keyboard.isControlPressed || mods.hasCtrl, + }; + } + + bool _isHoverKind(PointerDeviceKind kind) => switch (kind) { + .mouse || .stylus || .invertedStylus => true, + _ => false, + }; + + bool _isMouseTracked() { + return _attachment.mouseTracking != .none && + !HardwareKeyboard.instance.isShiftPressed && + !_attachment.virtualMods.hasShift; + } + + MouseButton? _mouseButtonForBit(int button) => _mouseButtons[button]; + + void _releaseSelectionPress([Position? cell]) { + cell ??= _pressCell; + if (cell == null) return; + _attachment.handleSelectionRelease(cell); + _pressCell = null; + } + + void _releaseTrackedPointer( + int pointerId, + Offset position, { + TerminalViewAttachment? attachment, + }) { + final pointer = _activePointers[pointerId]; + if (pointer == null) return; + pointer.position = position; + if (pointer.kind == .mouse) { + _updateMouseButtons(pointer, 0, position, attachment: attachment); + } else if (pointer.kind == .stylus || pointer.kind == .invertedStylus) { + _updateStylusButton(pointer, 0, position, attachment: attachment); + } else if (pointer.kind == .touch && pointer.tapCandidate) { + pointer.buttons = kPrimaryButton; + _sendMouseEvent( + .press, + position, + button: pointer.button, + attachment: attachment, + ); + pointer.buttons = 0; + _sendMouseEvent( + .release, + position, + button: pointer.button, + attachment: attachment, + ); + } + _activePointers.remove(pointerId); + } + + void _releaseTrackedPointers(TerminalViewAttachment attachment) { + _activePointers.removeWhere((_, pointer) => pointer.kind == .touch); + while (_activePointers.isNotEmpty) { + final entry = _activePointers.entries.first; + _releaseTrackedPointer( + entry.key, + entry.value.position, + attachment: attachment, + ); + } + } + + void _sendMouseEvent( + MouseAction action, + Offset position, { + MouseButton? button, + TerminalViewAttachment? attachment, + }) { + final target = attachment ?? _attachment; + target.handleMouseEvent( + TerminalMouseEvent( + action: action, + anyButtonPressed: _activePointers.values.any( + (pointer) => pointer.buttons != 0, + ), + button: button, + mods: target.currentMods, + pixelX: position.dx, + pixelY: position.dy, + ), + ); + } + + void _startAutoScroll() { + if (_autoScrollTimer != null) return; + final scrollController = widget.scrollController; + if (scrollController == null || !scrollController.hasClients) return; + _autoScrollTimer = Timer.periodic( + const Duration(milliseconds: 50), + _autoScrollTick, + ); + } + + void _startDrag( + Offset position, { + bool rectangle = false, + bool beginPress = false, + required Duration? timeStamp, + }) { + final cell = widget.metrics.cellAt(position); + final block = rectangle || _isBlockModifierPressed(); + if (_drag == null) HardwareKeyboard.instance.addHandler(_handleModifierKey); + _drag = _DragState( + cell, + position, + fixedRectangle: rectangle, + lastRectangle: block, + ); + if (beginPress) _handleSelectionPress(position, timeStamp); + } + + void _stopAutoScroll() { + _autoScrollTimer?.cancel(); + _autoScrollTimer = null; + } + + MouseButton? _stylusButtonForMask(int buttons) { + const supported = + kStylusContact | kPrimaryStylusButton | kSecondaryStylusButton; + if (buttons & ~supported != 0) return null; + final barrel = buttons & (kPrimaryStylusButton | kSecondaryStylusButton); + return switch (barrel) { + 0 when buttons == kStylusContact => .left, + kPrimaryStylusButton => .right, + kSecondaryStylusButton => .middle, + _ => null, + }; + } + + void _updateDrag(Offset position) { + final drag = _drag; + if (drag == null) return; + final cell = widget.metrics.cellAt(position); + drag.cell = cell; + drag.localPosition = position; + + final visibleRows = _attachment.terminal.geometry.rows; + if (visibleRows > 0) { + if (cell.row < 0 || cell.row >= visibleRows) { + _startAutoScroll(); + } else { + _stopAutoScroll(); + } + } + + final clampedRow = visibleRows > 0 + ? cell.row.clamp(0, visibleRows - 1) + : cell.row; + final clampedCell = Position(row: clampedRow, col: cell.col); + final rectangle = drag.fixedRectangle || _isBlockModifierPressed(); + if (clampedCell == drag.lastCell && rectangle == drag.lastRectangle) { + return; + } + drag.lastCell = clampedCell; + drag.lastRectangle = rectangle; + + _attachment.updateSelectionDrag( + TerminalSelectionDragEvent( + cell: clampedCell, + pixelX: position.dx, + pixelY: position.dy, + rectangle: rectangle, + ), + ); + } + + void _updateMouseButtons( + _TrackedPointer pointer, + int buttons, + Offset position, { + TerminalViewAttachment? attachment, + }) { + final nextButtons = buttons & _supportedMouseButtons; + final previousButtons = pointer.buttons; + final removed = previousButtons & ~nextButtons; + final added = nextButtons & ~previousButtons; + pointer.buttons = nextButtons; + + for (final entry in _mouseButtons.entries) { + if (removed & entry.key == 0) continue; + _sendMouseEvent( + .release, + position, + button: entry.value, + attachment: attachment, + ); + } + + for (final entry in _mouseButtons.entries) { + if (added & entry.key == 0) continue; + pointer.button = entry.value; + _sendMouseEvent( + .press, + position, + button: entry.value, + attachment: attachment, + ); + } + + if (pointer.buttons != 0 && + !_mouseButtons.entries.any( + (entry) => + entry.value == pointer.button && pointer.buttons & entry.key != 0, + )) { + pointer.button = _mouseButtonForBit(smallestButton(pointer.buttons))!; + } + pointer.position = position; + } + + void _updateStylusButton( + _TrackedPointer pointer, + int buttons, + Offset position, { + TerminalViewAttachment? attachment, + }) { + final previousButton = pointer.buttons == 0 ? null : pointer.button; + final nextButton = _stylusButtonForMask(buttons); + pointer.buttons = nextButton == null ? 0 : kPrimaryButton; + + if (previousButton != nextButton) { + if (previousButton != null) { + _sendMouseEvent( + .release, + position, + button: previousButton, + attachment: attachment, + ); + } + if (nextButton != null) { + pointer.button = nextButton; + _sendMouseEvent( + .press, + position, + button: nextButton, + attachment: attachment, + ); + } + } + pointer.position = position; + } +} + +final class _DragState { + final bool fixedRectangle; + Position cell; + Position? lastCell; + bool lastRectangle; + Offset localPosition; + + _DragState( + this.cell, + this.localPosition, { + required this.fixedRectangle, + required this.lastRectangle, + }); +} + +final class _TrackedPointer { + final Offset downPosition; + final PointerDeviceKind kind; + MouseButton button; + int buttons; + Offset position; + bool tapCandidate; + + _TrackedPointer({ + required this.button, + required this.buttons, + required this.kind, + required this.position, + required this.tapCandidate, + }) : downPosition = position; +} diff --git a/packages/flterm/lib/src/input/terminal_input_adapter.dart b/packages/flterm/lib/src/input/terminal_input_adapter.dart new file mode 100644 index 00000000..ce99881c --- /dev/null +++ b/packages/flterm/lib/src/input/terminal_input_adapter.dart @@ -0,0 +1,265 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:libghostty/libghostty.dart' hide KeyEvent; + +import '../controller/terminal_controller.dart'; +import '../foundation.dart'; +import 'terminal_input_client.dart'; +import 'terminal_input_event.dart'; + +/// Adapts one Flutter focus and keyboard lifecycle to terminal input. +/// +/// The adapter combines physical and virtual modifiers, routes raw key events, +/// owns the view's [TerminalInputClient], and publishes visible IME preedit +/// text. Terminal protocol encoding remains controller-owned. +/// +/// Attachment is view-bound: replacing the focus node or Flutter view ID +/// detaches the platform text-input connection before rebinding it. Raw keys +/// and text deltas converge on controller methods, so neither path writes +/// directly to libghostty or invokes public output callbacks. +@internal +final class TerminalInputAdapter extends ChangeNotifier { + static const _space = 0x20; + static const _delete = 0x7f; + static const _macFunctionKeyStart = 0xF700; + static const _macFunctionKeyEnd = 0xF8FF; + + final TerminalControllerImpl _controller; + final _textInput = TerminalInputClient(); + FocusNode? _focusNode; + Brightness _keyboardAppearance = .dark; + var _preeditText = ''; + var _wasFocused = false; + + TerminalInputAdapter(this._controller) { + _textInput + ..onTextCommitted = _controller.handleTextCommitted + ..onDelete = _controller.handleTextDeleted + ..onNewline = _controller.handleTextNewline + ..onPreeditChanged = _handlePreeditChanged; + } + + set keyboardAppearance(Brightness value) { + if (_keyboardAppearance == value) return; + _keyboardAppearance = value; + _textInput.keyboardAppearance = value; + } + + String get preeditText => _preeditText; + + Mods get _currentMods { + var mods = _controller.virtualMods; + final keyboard = HardwareKeyboard.instance; + if (keyboard.isShiftPressed) mods |= const Mods.shift(); + if (keyboard.isControlPressed) mods |= const Mods.ctrl(); + if (keyboard.isAltPressed) mods |= const Mods.alt(); + if (keyboard.isMetaPressed) mods |= const Mods.superKey(); + final lockModes = keyboard.lockModesEnabled; + if (lockModes.contains(KeyboardLockMode.capsLock)) { + mods |= const Mods.capsLock(); + } + if (lockModes.contains(KeyboardLockMode.numLock)) { + mods |= const Mods.numLock(); + } + return mods; + } + + bool get _isDesktopPlatform { + if (kIsWeb) return false; + return switch (defaultTargetPlatform) { + .linux || .macOS || .windows => true, + .android || .fuchsia || .iOS => false, + }; + } + + void attach(FocusNode focusNode, {required int viewId}) { + final previousFocusNode = _focusNode; + final wasFocused = _wasFocused; + previousFocusNode?.removeListener(_handleFocusChanged); + if (previousFocusNode != null && !identical(previousFocusNode, focusNode)) { + _textInput.detach(); + } + _focusNode = focusNode; + _wasFocused = focusNode.hasFocus; + focusNode.addListener(_handleFocusChanged); + _textInput + ..viewId = viewId + ..keyboardAppearance = _keyboardAppearance; + if (_wasFocused) { + if (!wasFocused) _controller.handleFocusChanged(focused: true); + _textInput.ensureAttached(keyboardAppearance: _keyboardAppearance); + } + } + + void detach() { + if (_wasFocused && !_controller.isDisposed) { + _controller.handleFocusChanged(focused: false); + } + _focusNode?.removeListener(_handleFocusChanged); + _focusNode = null; + _wasFocused = false; + _preeditText = ''; + _textInput.detach(); + } + + @override + void dispose() { + detach(); + super.dispose(); + } + + KeyEventResult handleKeyEvent(KeyEvent event) { + final KeyAction? action = switch (event) { + KeyDownEvent() => .press, + KeyUpEvent() => .release, + KeyRepeatEvent() => .repeat, + _ => null, + }; + if (action == null) return .ignored; + + final key = keyFromPhysical(event.physicalKey); + final unshiftedCodepoint = unshiftedCodepointForKey(key); + final character = _encoderCharacter(event.character); + final virtualMods = _controller.virtualMods; + final mods = _currentMods; + final physicalConsumedMods = _consumedModsFor( + character, + unshiftedCodepoint: unshiftedCodepoint, + mods: mods, + ); + final consumedMods = + physicalConsumedMods ^ (physicalConsumedMods & virtualMods); + final terminalMods = consumedMods.hasCtrl ? mods ^ const .ctrl() : mods; + final composing = + _textInput.hasActiveComposition || _preeditText.isNotEmpty; + final input = TerminalKeyInput( + key: key, + action: action, + mods: terminalMods, + character: character, + composing: composing, + consumedMods: consumedMods, + unshiftedCodepoint: unshiftedCodepoint, + ); + + if (_shouldForwardCompositionKey(input)) return .skipRemainingHandlers; + + final disposition = _controller.handleTerminalKey( + input, + routeToTextInput: _shouldRouteToTextInput(input), + forwardDeletionToTextInput: _shouldForwardDeletion(input), + ); + return switch (disposition) { + .ignored => .ignored, + .handled => .handled, + .skipRemainingHandlers => .skipRemainingHandlers, + }; + } + + void requestFocus() => _focusNode?.requestFocus(); + + void showKeyboard() { + _focusNode?.requestFocus(); + if (_focusNode?.hasFocus ?? false) _textInput.show(); + } + + void updateTextInputGeometry({ + required Size editableSize, + required Matrix4 transform, + required Rect caretRect, + required Rect composingRect, + }) { + _textInput.updateGeometry( + editableSize: editableSize, + transform: transform, + caretRect: caretRect, + composingRect: composingRect, + ); + } + + void _handleFocusChanged() { + final focused = _focusNode?.hasFocus ?? false; + if (focused == _wasFocused) return; + _wasFocused = focused; + _controller.handleFocusChanged(focused: focused); + if (focused) { + _textInput.ensureAttached(keyboardAppearance: _keyboardAppearance); + } else { + _textInput.hide(); + } + } + + void _handlePreeditChanged(String value) { + if (_preeditText == value) return; + _preeditText = value; + _controller.handleTextCompositionChanged(active: value.isNotEmpty); + notifyListeners(); + } + + bool _shouldForwardCompositionKey(TerminalKeyInput input) { + return input.composing && + _textInput.isAttached && + _isDesktopPlatform && + !_shouldRouteToTextInput(input); + } + + bool _shouldForwardDeletion(TerminalKeyInput input) { + if (!_isDesktopPlatform || !_controller.virtualMods.isEmpty) return false; + if (input.action != .press && input.action != .repeat) return false; + if (input.key != .backspace && input.key != .delete) return false; + final mods = input.mods; + if (mods.hasShift || mods.hasCtrl || mods.hasAlt || mods.hasSuper) { + return false; + } + return _textInput.consumeCommittedCompositionEdit(); + } + + bool _shouldRouteToTextInput(TerminalKeyInput input) { + if (input.character == null || input.composing) return false; + if (!_textInput.isAttached || !_isDesktopPlatform) return false; + if (input.action != .press && input.action != .repeat) return false; + if (!_controller.virtualMods.isEmpty) return false; + final mods = input.mods; + final consumedMods = input.consumedMods; + return !(mods.hasCtrl && !consumedMods.hasCtrl) && + !(mods.hasAlt && !consumedMods.hasAlt) && + !mods.hasSuper; + } + + static Mods _consumedModsFor( + String? character, { + required int unshiftedCodepoint, + required Mods mods, + }) { + if (character == null || unshiftedCodepoint == 0) return const .none(); + + final codepoints = character.runes.iterator; + if (!codepoints.moveNext()) return const .none(); + final codepoint = codepoints.current; + if (codepoints.moveNext() || codepoint == unshiftedCodepoint) { + return const .none(); + } + + var consumedMods = const Mods.none(); + if (mods.hasShift) consumedMods |= const Mods.shift(); + + final keyboard = HardwareKeyboard.instance; + final rightAltPressed = keyboard.isLogicalKeyPressed( + LogicalKeyboardKey.altRight, + ); + if (mods.hasAlt && rightAltPressed) { + consumedMods |= const .alt(); + if (keyboard.isControlPressed) consumedMods |= const .ctrl(); + } + return consumedMods; + } + + static String? _encoderCharacter(String? character) { + if (character == null || character.isEmpty) return null; + final code = character.codeUnitAt(0); + if (code < _space || code == _delete) return null; + if (code >= _macFunctionKeyStart && code <= _macFunctionKeyEnd) return null; + return character; + } +} diff --git a/packages/flterm/lib/src/widgets/terminal_input_client.dart b/packages/flterm/lib/src/input/terminal_input_client.dart similarity index 89% rename from packages/flterm/lib/src/widgets/terminal_input_client.dart rename to packages/flterm/lib/src/input/terminal_input_client.dart index 275f9448..5db40b6e 100644 --- a/packages/flterm/lib/src/widgets/terminal_input_client.dart +++ b/packages/flterm/lib/src/input/terminal_input_client.dart @@ -9,30 +9,35 @@ import 'package:meta/meta.dart'; /// editing value only to give platform IMEs an anchor for composing ranges. /// It turns platform edits into terminal events: committed text, newlines, /// deletions, and visible preedit text. +/// +/// The sentinel never becomes terminal content. Delta edits are translated +/// relative to it, then the platform value is reset after each commit. Some +/// platforms report Enter through both an action and a delta; the short-lived +/// dedupe counters ensure one terminal newline while leaving later input +/// untouched. @internal final class TerminalInputClient with DeltaTextInputClient { - static final _newlinePattern = RegExp(r'\r\n|[\n\r]'); - static final _singleNewlinePattern = RegExp(r'^(?:\r\n|[\n\r])$'); static const _newlineActionDedupeWindow = Duration(milliseconds: 100); static const _sentinel = TextEditingValue( selection: .collapsed(offset: 1), text: ' ', ); + static final _newlinePattern = RegExp(r'\r\n|[\n\r]'); + static final _onlyNewlinesPattern = RegExp(r'^(?:\r\n|[\n\r])+$'); - int? _viewId; + _CommittedCompositionEdit _committedCompositionEdit = .none; TextInputConnection? _connection; - TextEditingValue _value = _sentinel; + var _hadVisiblePreeditText = false; Brightness _keyboardAppearance = .dark; + var _newlineActionsToSuppress = 0; Timer? _newlineActionDedupeTimer; - var _suppressNextNewlineDelta = false; - var _suppressNextNewlineAction = false; - _CommittedCompositionEdit _committedCompositionEdit = .none; - var _hadVisiblePreeditText = false; - + var _newlineDeltasToSuppress = 0; VoidCallback? _onNewline; ValueChanged? _onDelete; - ValueChanged? _onTextCommitted; ValueChanged? _onPreeditChanged; + ValueChanged? _onTextCommitted; + TextEditingValue _value = _sentinel; + int? _viewId; @override AutofillScope? get currentAutofillScope => null; @@ -104,12 +109,6 @@ final class TerminalInputClient with DeltaTextInputClient { ); } - void attach({Brightness keyboardAppearance = .dark}) { - _closeConnection(); - _keyboardAppearance = keyboardAppearance; - _openConnection(); - } - @override void connectionClosed() { _connection = null; @@ -129,7 +128,7 @@ final class TerminalInputClient with DeltaTextInputClient { @override void didChangeInputControl(TextInputControl? _, TextInputControl? _) {} - void ensureAttached({Brightness keyboardAppearance = Brightness.dark}) { + void ensureAttached({Brightness keyboardAppearance = .dark}) { _keyboardAppearance = keyboardAppearance; final connection = _connection; if (connection == null) return _openConnection(); @@ -154,12 +153,14 @@ final class TerminalInputClient with DeltaTextInputClient { @override void performAction(TextInputAction action) { if (action != .newline) return; - if (_suppressNextNewlineAction) { - _clearNewlineActionSuppression(); + if (_newlineActionsToSuppress > 0) { + _newlineActionsToSuppress--; + _finishNewlineDedupeIfIdle(); return; } _onNewline?.call(); - _suppressNextNewlineDeltaSoon(); + _newlineDeltasToSuppress++; + _armNewlineActionDedupeTimer(); } @override @@ -243,8 +244,8 @@ final class TerminalInputClient with DeltaTextInputClient { void _clearNewlineActionSuppression() { _newlineActionDedupeTimer?.cancel(); _newlineActionDedupeTimer = null; - _suppressNextNewlineDelta = false; - _suppressNextNewlineAction = false; + _newlineActionsToSuppress = 0; + _newlineDeltasToSuppress = 0; } void _closeConnection() { @@ -271,13 +272,31 @@ final class TerminalInputClient with DeltaTextInputClient { _resetBuffer(); } + void _commitNewlines(int count) { + final suppressed = count < _newlineDeltasToSuppress + ? count + : _newlineDeltasToSuppress; + _newlineDeltasToSuppress -= suppressed; + final emitted = count - suppressed; + for (var i = 0; i < emitted; i++) { + _onNewline?.call(); + } + _newlineActionsToSuppress += emitted; + if (_newlineActionsToSuppress > 0 || _newlineDeltasToSuppress > 0) { + _armNewlineActionDedupeTimer(); + } else { + _finishNewlineDedupeIfIdle(); + } + } + void _commitText(String text) { - final singleNewline = _singleNewlinePattern.hasMatch(text); - if (singleNewline && _suppressNextNewlineDelta) { - _clearNewlineActionSuppression(); + if (_onlyNewlinesPattern.hasMatch(text)) { + _commitNewlines(_newlinePattern.allMatches(text).length); return; } + _clearNewlineActionSuppression(); + var offset = 0; for (final match in _newlinePattern.allMatches(text)) { final chunk = text.substring(offset, match.start); @@ -288,7 +307,12 @@ final class TerminalInputClient with DeltaTextInputClient { final tail = text.substring(offset); if (tail.isNotEmpty) _onTextCommitted?.call(tail); - if (singleNewline) _suppressNextNewlineActionSoon(); + } + + void _finishNewlineDedupeIfIdle() { + if (_newlineActionsToSuppress == 0 && _newlineDeltasToSuppress == 0) { + _clearNewlineActionSuppression(); + } } void _openConnection() { @@ -332,6 +356,7 @@ final class TerminalInputClient with DeltaTextInputClient { return; } final count = delta.deletedRange.end - delta.deletedRange.start; + _clearNewlineActionSuppression(); _onDelete?.call(count); _clearCommittedCompositionEdit(); _resetBuffer(); @@ -386,16 +411,6 @@ final class TerminalInputClient with DeltaTextInputClient { _hadVisiblePreeditText = false; if (hadVisiblePreeditText) _onPreeditChanged?.call(''); } - - void _suppressNextNewlineActionSoon() { - _suppressNextNewlineAction = true; - _armNewlineActionDedupeTimer(); - } - - void _suppressNextNewlineDeltaSoon() { - _suppressNextNewlineDelta = true; - _armNewlineActionDedupeTimer(); - } } enum _CommittedCompositionEdit { none, pending, suppressNextDeletionDelta } diff --git a/packages/flterm/lib/src/input/terminal_input_encoder.dart b/packages/flterm/lib/src/input/terminal_input_encoder.dart new file mode 100644 index 00000000..5daa64b3 --- /dev/null +++ b/packages/flterm/lib/src/input/terminal_input_encoder.dart @@ -0,0 +1,126 @@ +import 'package:libghostty/libghostty.dart' hide TerminalGeometry; + +import '../foundation.dart'; +import 'terminal_input_event.dart'; + +/// Owns the reusable terminal resources that encode normalized input. +/// +/// It translates renderer-neutral key and pointer values into terminal bytes +/// without owning Flutter focus, gesture, or text-input lifecycle. Reusing the +/// native events and encoders avoids allocations on input hot paths. +final class TerminalInputEncoder { + final Terminal _terminal; + final _keyEvent = KeyEvent(); + final _mouseEvent = MouseEvent(); + final _keyEncoder = KeyEncoder(); + final _mouseEncoder = MouseEncoder(); + + TerminalInputEncoder(this._terminal); + + void dispose() { + _keyEvent.dispose(); + _mouseEvent.dispose(); + _keyEncoder.dispose(); + _mouseEncoder.dispose(); + } + + String encodeKey(TerminalKeyInput input) { + _keyEvent + ..key = input.key + ..mods = input.mods + ..action = input.action + ..utf8 = input.character + ..consumedMods = input.consumedMods + ..unshiftedCodepoint = input.unshiftedCodepoint + ..composing = input.composing; + return _encodeKeyEvent(); + } + + String encodeKeyPress(Key key, {required Mods mods}) { + final codepoint = unshiftedCodepointForKey(key); + _keyEvent + ..key = key + ..mods = mods + ..action = .press + ..consumedMods = const .none() + ..unshiftedCodepoint = codepoint + ..utf8 = codepoint > 0 ? String.fromCharCode(codepoint) : null + ..composing = false; + return _encodeKeyEvent(); + } + + String encodeMouse( + TerminalMouseEvent event, { + required TerminalGeometry? geometry, + }) { + _mouseEvent + ..action = event.action + ..mods = event.mods; + _setMousePosition(event.pixelX, event.pixelY, geometry); + if (event.button case final button?) { + _mouseEvent.button = button; + } else { + _mouseEvent.clearButton(); + } + _mouseEncoder.sync(_terminal); + _mouseEncoder.setAnyButtonPressed(pressed: event.anyButtonPressed); + return _mouseEncoder.encode(_mouseEvent); + } + + String encodeScrollButton({ + required MouseButton button, + required double pixelX, + required double pixelY, + required Mods mods, + required TerminalGeometry? geometry, + }) { + var x = pixelX; + var y = pixelY; + if (geometry != null) { + final width = geometry.cols * geometry.cellWidth; + final height = geometry.rows * geometry.cellHeight; + final edge = 1 / geometry.devicePixelRatio; + x = x.clamp(0.0, width > edge ? width - edge : 0.0); + y = y.clamp(0.0, height > edge ? height - edge : 0.0); + } + _mouseEvent + ..action = .press + ..button = button + ..mods = mods; + _setMousePosition(x, y, geometry); + _mouseEncoder.sync(_terminal); + _mouseEncoder.setAnyButtonPressed(pressed: false); + return _mouseEncoder.encode(_mouseEvent); + } + + void updateGeometry(TerminalGeometry geometry) { + _mouseEncoder.setSize( + MouseEncoderSize( + screenWidth: geometry.screenWidth, + screenHeight: geometry.screenHeight, + cellWidth: geometry.cellWidthPx, + cellHeight: geometry.cellHeightPx, + paddingLeft: geometry.paddingLeftPx, + paddingRight: geometry.paddingRightPx, + paddingTop: geometry.paddingTopPx, + paddingBottom: geometry.paddingBottomPx, + ), + ); + } + + String _encodeKeyEvent() { + _keyEncoder.sync(_terminal); + return _keyEncoder.encode(_keyEvent); + } + + void _setMousePosition(double x, double y, TerminalGeometry? geometry) { + if (geometry == null) { + _mouseEvent.setPosition(x: x, y: y); + return; + } + _mouseEvent.setPosition( + x: x * geometry.devicePixelRatio + geometry.paddingLeftPx, + y: y * geometry.devicePixelRatio + geometry.paddingTopPx, + ); + } +} diff --git a/packages/flterm/lib/src/input/terminal_input_event.dart b/packages/flterm/lib/src/input/terminal_input_event.dart new file mode 100644 index 00000000..8e5c6875 --- /dev/null +++ b/packages/flterm/lib/src/input/terminal_input_event.dart @@ -0,0 +1,107 @@ +import 'package:libghostty/libghostty.dart'; + +/// The outcome of routing normalized keyboard input. +enum TerminalKeyDisposition { + /// The terminal did not consume the input. + ignored, + + /// The terminal consumed the input. + handled, + + /// The terminal consumed the input and remaining handlers must be skipped. + skipRemainingHandlers, +} + +/// Keyboard input normalized independently of Flutter key event types. +final class TerminalKeyInput { + /// The terminal key action. + final KeyAction action; + + /// The text associated with the key event, or null when it has none. + final String? character; + + /// Whether a platform input method owns the key sequence. + final bool composing; + + /// The modifiers consumed to produce [character]. + final Mods consumedMods; + + /// The physical key translated to the terminal engine's key vocabulary. + final Key key; + + /// The effective modifier state presented to the terminal encoder. + final Mods mods; + + /// The key's code point without modifiers, or zero when unavailable. + final int unshiftedCodepoint; + + const TerminalKeyInput({ + required this.action, + required this.character, + required this.composing, + required this.consumedMods, + required this.key, + required this.mods, + required this.unshiftedCodepoint, + }); +} + +/// Normalized mouse input for the terminal protocol. +final class TerminalMouseEvent { + /// The mouse action being reported. + final MouseAction action; + + /// Whether any terminal-reportable pointer button remains pressed. + final bool anyButtonPressed; + + /// The button associated with [action], or null for unbuttoned motion. + final MouseButton? button; + + /// The complete physical and virtual modifier state for this event. + final Mods mods; + + /// The logical horizontal offset from the terminal grid origin. + final double pixelX; + + /// The logical vertical offset from the terminal grid origin. + final double pixelY; + + const TerminalMouseEvent({ + required this.action, + required this.anyButtonPressed, + required this.button, + required this.mods, + required this.pixelX, + required this.pixelY, + }); +} + +/// Quantized terminal scroll input captured for one gesture target. +final class TerminalScrollEvent { + /// Signed horizontal cell steps. Negative values scroll left. + final int horizontal; + + /// The modifier state captured when the scroll target was selected. + final Mods mods; + + /// The target's logical horizontal offset from the terminal grid origin. + final double pixelX; + + /// The target's logical vertical offset from the terminal grid origin. + final double pixelY; + + /// Whether to encode mouse reports instead of alternate-scroll keys. + final bool reportMouse; + + /// Signed vertical cell steps. Negative values scroll up. + final int vertical; + + const TerminalScrollEvent({ + required this.horizontal, + required this.mods, + required this.pixelX, + required this.pixelY, + required this.reportMouse, + required this.vertical, + }); +} diff --git a/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart b/packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart similarity index 55% rename from packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart rename to packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart index 9123770c..6072da57 100644 --- a/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart +++ b/packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart @@ -2,24 +2,18 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -/// Gesture detector that recognizes taps, mouse drags, and touch long presses. +/// Recognizes the primitive gestures used by terminal interaction. /// -/// Drag is restricted to mouse devices, long press to touch devices. -/// -/// ```dart -/// TerminalRawGestureDetector( -/// onTapDown: (details) => handleTapDown(details), -/// onTapUp: (details) => handleTapUp(details), -/// onDragStart: (details) => handleDragStart(details), -/// child: Container(), -/// ) -/// ``` +/// Pan is restricted to mouse, stylus, and inverted stylus; long press is +/// restricted to touch. The tap recognizer retains the primary pointer's +/// source timestamp because Flutter's resolved tap details do not expose it. +/// This widget reports gestures only and owns no selection or terminal state. @internal -class TerminalRawGestureDetector extends StatelessWidget { +final class TerminalRawGestureDetector extends StatelessWidget { final Widget child; - /// Fires when a tap begins. - final GestureTapDownCallback? onTapDown; + /// Fires when a tap begins with its source pointer timestamp. + final void Function(TapDownDetails details, Duration timeStamp)? onTapDown; /// Fires when a tap ends. final GestureTapUpCallback? onTapUp; @@ -58,23 +52,27 @@ class TerminalRawGestureDetector extends StatelessWidget { @override Widget build(BuildContext context) { return RawGestureDetector( - behavior: HitTestBehavior.opaque, + behavior: .opaque, gestures: { - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (instance) => instance - ..onTapDown = onTapDown - ..onTapUp = onTapUp, - ), + _TimestampedTapGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _TimestampedTapGestureRecognizer + >(() => _TimestampedTapGestureRecognizer(debugOwner: this), ( + recognizer, + ) { + recognizer.onTapDown = onTapDown == null + ? null + : (details) => onTapDown!(details, recognizer.timeStamp); + recognizer.onTapUp = onTapUp; + }), LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer( debugOwner: this, - supportedDevices: const {PointerDeviceKind.touch}, + supportedDevices: const {.touch}, ), (instance) => instance - ..onLongPressStart = onLongPressStart?.call + ..onLongPressStart = onLongPressStart ..onLongPressMoveUpdate = onLongPressMoveUpdate ..onLongPressUp = onLongPressUp, ), @@ -82,7 +80,7 @@ class TerminalRawGestureDetector extends StatelessWidget { GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer( debugOwner: this, - supportedDevices: const {PointerDeviceKind.mouse}, + supportedDevices: const {.mouse, .stylus, .invertedStylus}, ), (instance) { instance @@ -98,3 +96,16 @@ class TerminalRawGestureDetector extends StatelessWidget { ); } } + +/// Retains the primary pointer timestamp until Flutter resolves the tap arena. +final class _TimestampedTapGestureRecognizer extends TapGestureRecognizer { + Duration timeStamp = .zero; + + _TimestampedTapGestureRecognizer({super.debugOwner}); + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + if (primaryPointer == event.pointer) timeStamp = event.timeStamp; + } +} diff --git a/packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart b/packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart new file mode 100644 index 00000000..05f01a6d --- /dev/null +++ b/packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart @@ -0,0 +1,683 @@ +import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:libghostty/libghostty.dart' show Mods; +import 'package:meta/meta.dart'; + +import '../foundation.dart'; +import '../view/terminal_view_attachment.dart'; +import 'terminal_input_event.dart'; + +typedef _ScrollTarget = ({Offset position, Mods mods, bool reportMouse}); + +enum _ScrollGestureMode { pan, vertical } + +/// Owns terminal-directed wheel, touch, and trackpad scrolling. +/// +/// This component captures one target for each gesture sequence, quantizes +/// pixel motion into terminal cell steps, and continues flings through +/// Flutter's [ScrollPhysics]. Mouse reporting uses a two-dimensional pan +/// recognizer; alternate-screen key scrolling uses a vertical recognizer so +/// horizontal gestures remain available to ancestor widgets. +@internal +final class TerminalScrollGestureHandler extends StatefulWidget { + final Widget child; + final CellMetrics metrics; + final ScrollPhysics physics; + final TerminalViewAttachment attachment; + final TerminalInteractionState interaction; + final ValueChanged onScrollStart; + + const TerminalScrollGestureHandler({ + super.key, + required this.metrics, + required this.physics, + required this.attachment, + required this.interaction, + required this.onScrollStart, + required this.child, + }); + + @override + State createState() => + _TerminalScrollGestureState(); +} + +final class _TerminalScrollGestureState + extends State + with SingleTickerProviderStateMixin { + static const _macOsDiscreteScrollPixels = 40.0; + static const _macOsDiscreteVerticalMultiplier = 3.0; + + late final Ticker _ticker; + _ScrollActivity? _activity; + _ScrollRemainder? _remainder; + + @override + Widget build(BuildContext context) { + return Listener( + behavior: .opaque, + onPointerSignal: _handlePointerSignal, + child: RawGestureDetector( + behavior: .opaque, + gestures: { + _TerminalPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _TerminalPanGestureRecognizer + >( + () => _TerminalPanGestureRecognizer(debugOwner: this), + (recognizer) => _configure(recognizer, .pan), + ), + _TerminalVerticalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _TerminalVerticalDragGestureRecognizer + >( + () => _TerminalVerticalDragGestureRecognizer(debugOwner: this), + (recognizer) => _configure(recognizer, .vertical), + ), + }, + child: widget.child, + ), + ); + } + + @override + void didUpdateWidget(TerminalScrollGestureHandler oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.attachment != oldWidget.attachment || + widget.metrics != oldWidget.metrics || + widget.interaction != oldWidget.interaction || + widget.physics != oldWidget.physics) { + _reset(); + } + } + + @override + void dispose() { + _cancelGesture(); + _ticker.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _ticker = createTicker(_tick); + } + + void _configure(DragGestureRecognizer recognizer, _ScrollGestureMode mode) { + (recognizer as _TerminalScrollSequence).configureSequence( + canStart: () => _gestureMode() == mode, + onPointerStart: _beginGesture, + ); + final configuration = ScrollConfiguration.of(context); + recognizer + ..dragStartBehavior = .down + ..multitouchDragStrategy = configuration.getMultitouchDragStrategy( + context, + ) + ..onUpdate = _updateGesture + ..onEnd = _endGesture + ..onCancel = _cancelGesture + ..minFlingDistance = widget.physics.minFlingDistance + ..minFlingVelocity = widget.physics.minFlingVelocity + ..maxFlingVelocity = widget.physics.maxFlingVelocity + ..velocityTrackerBuilder = configuration.velocityTrackerBuilder(context) + ..gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + } + + _ScrollGestureMode? _gestureMode() { + final metrics = widget.metrics; + if ((_activity?.isDragging ?? false) || + !widget.physics.allowUserScrolling || + !metrics.cellWidth.isFinite || + metrics.cellWidth <= 0 || + !metrics.cellHeight.isFinite || + metrics.cellHeight <= 0) { + return null; + } + if (widget.attachment.mouseTracking != .none) { + return widget.attachment.currentMods.hasShift ? null : .pan; + } + final terminal = widget.attachment.terminal; + return terminal.activeScreen == .alternate && + terminal.modeGet(const .alternateScroll()) + ? .vertical + : null; + } + + void _beginGesture(PointerEvent event) { + final carriedVelocity = _activity?.velocity ?? Offset.zero; + _stopBallistic(); + _activity = _ScrollActivity( + target: _targetAt(event.localPosition), + kind: event.kind, + physics: widget.physics, + carriedVelocity: carriedVelocity, + timeStamp: event.timeStamp, + ); + } + + void _updateGesture(DragUpdateDetails details) { + final activity = _activity; + if (activity == null || !activity.isDragging) return; + final delta = -details.delta; + if (activity.markMoved(delta)) widget.onScrollStart(activity.kind); + final adjusted = activity.update(delta, details.sourceTimeStamp); + if (adjusted != Offset.zero) _route(adjusted, activity.target); + } + + void _endGesture(DragEndDetails details) { + final activity = _activity; + if (activity == null || !activity.isDragging) return; + final velocity = -details.velocity.pixelsPerSecond; + if (activity.markMoved(velocity)) widget.onScrollStart(activity.kind); + if (!activity.startBallistic( + physics: widget.physics, + velocity: velocity, + viewportSize: context.size ?? Size.zero, + devicePixelRatio: View.of(context).devicePixelRatio, + )) { + _activity = null; + return; + } + _ticker.start(); + } + + void _cancelGesture() { + _activity = null; + _ticker.stop(); + } + + void _reset() { + _cancelGesture(); + _remainder = null; + } + + void _handlePointerSignal(PointerSignalEvent event) { + if (event is PointerScrollInertiaCancelEvent) { + _stopBallistic(); + return; + } + if (event is! PointerScrollEvent || + event.scrollDelta == .zero || + _gestureMode() == null) { + return; + } + final target = _targetAt(event.localPosition); + final delta = _supportedDelta(_normalizePointerScroll(event), target); + if (delta == Offset.zero) return; + GestureBinding.instance.pointerSignalResolver.register( + event, + (resolvedEvent) => _handleResolvedPointerSignal( + resolvedEvent, + delta: delta, + target: target, + ), + ); + } + + void _handleResolvedPointerSignal( + PointerSignalEvent event, { + required Offset delta, + required _ScrollTarget target, + }) { + if (event is! PointerScrollEvent) return; + _stopBallistic(); + widget.onScrollStart(event.kind); + _route(delta, target); + event.respond(allowPlatformDefault: false); + } + + _ScrollTarget _targetAt(Offset position) { + final mods = widget.attachment.currentMods; + return ( + mods: mods, + position: position, + reportMouse: widget.attachment.mouseTracking != .none && !mods.hasShift, + ); + } + + Offset _normalizePointerScroll(PointerScrollEvent event) { + if (kIsWeb || defaultTargetPlatform != .macOS || event.kind != .mouse) { + return event.scrollDelta; + } + + final delta = event.scrollDelta; + final metrics = widget.metrics; + return Offset( + _discreteHorizontalTicks(delta.dx) * metrics.cellWidth, + _discreteVerticalTicks(delta.dy) * + metrics.cellHeight * + _macOsDiscreteVerticalMultiplier, + ); + } + + int _discreteHorizontalTicks(double delta) { + if (delta == 0) return 0; + final magnitude = (delta.abs() / _macOsDiscreteScrollPixels).round(); + final ticks = magnitude < 1 ? 1 : magnitude; + return delta < 0 ? -ticks : ticks; + } + + double _discreteVerticalTicks(double delta) { + if (delta == 0) return 0; + final ticks = delta / _macOsDiscreteScrollPixels; + return ticks.abs() < 1 ? ticks.sign : ticks; + } + + void _route(Offset delta, _ScrollTarget target) { + final metrics = widget.metrics; + var remainder = _remainder; + if (remainder == null || !remainder.shares(target, metrics)) { + remainder = _ScrollRemainder(target, metrics); + _remainder = remainder; + } + + remainder.horizontal += delta.dx; + remainder.vertical += delta.dy; + final horizontal = (remainder.horizontal / metrics.cellWidth).truncate(); + final vertical = (remainder.vertical / metrics.cellHeight).truncate(); + if (horizontal != 0) remainder.horizontal -= horizontal * metrics.cellWidth; + if (vertical != 0) remainder.vertical -= vertical * metrics.cellHeight; + if (horizontal == 0 && vertical == 0) return; + + widget.attachment.handleTerminalScroll( + TerminalScrollEvent( + mods: target.mods, + vertical: vertical, + horizontal: horizontal, + pixelX: target.position.dx, + pixelY: target.position.dy, + reportMouse: target.reportMouse, + ), + ); + } + + void _tick(Duration elapsed) { + final activity = _activity; + if (activity == null || activity.isDragging) return; + final delta = activity.advance(elapsed); + if (delta != Offset.zero) _route(delta, activity.target); + if (activity.done) _stopBallistic(); + } + + void _stopBallistic() { + _ticker.stop(); + final activity = _activity; + if (activity == null) return; + if (activity.isDragging) { + activity.dropMomentum(); + } else { + _activity = null; + } + } + + static Offset _supportedDelta(Offset delta, _ScrollTarget target) { + return target.reportMouse ? delta : Offset(0, delta.dy); + } +} + +/// Carries one accepted drag into its optional ballistic continuation. +/// +/// Keeping both phases in one object preserves the captured terminal target +/// and per-axis momentum without parallel gesture and fling state in the +/// widget. Motion is converted to supported axes before it reaches the router. +final class _ScrollActivity { + final _ScrollAxis _horizontal; + final _ScrollAxis _vertical; + final PointerDeviceKind kind; + final _ScrollTarget target; + Duration _elapsed = .zero; + Offset _position = .zero; + bool _dragging; + bool _moved; + + _ScrollActivity({ + required this.kind, + required this.target, + required ScrollPhysics physics, + required Offset carriedVelocity, + required Duration? timeStamp, + }) : _horizontal = _ScrollAxis( + carriedVelocity: physics.carriedMomentum(carriedVelocity.dx), + motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, + timeStamp: timeStamp, + ), + _vertical = _ScrollAxis( + carriedVelocity: physics.carriedMomentum(carriedVelocity.dy), + motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, + timeStamp: timeStamp, + ), + _dragging = true, + _moved = false; + + bool get done => _horizontal.done && _vertical.done; + + bool get isDragging => _dragging; + + Offset get velocity { + final seconds = _seconds(_elapsed); + return Offset( + _horizontal.velocityAt(seconds), + _vertical.velocityAt(seconds), + ); + } + + Offset advance(Duration elapsed) { + _elapsed = elapsed; + final seconds = _seconds(elapsed); + final position = Offset( + _horizontal.positionAt(seconds, _position.dx), + _vertical.positionAt(seconds, _position.dy), + ); + final delta = position - _position; + _position = position; + return delta; + } + + void dropMomentum() { + _horizontal.dropMomentum(); + _vertical.dropMomentum(); + } + + bool markMoved(Offset delta) { + if (_supported(delta) == .zero || _moved) return false; + _moved = true; + return true; + } + + bool startBallistic({ + required ScrollPhysics physics, + required Offset velocity, + required Size viewportSize, + required double devicePixelRatio, + }) { + _dragging = false; + final supported = _supported(velocity); + _horizontal.startBallistic( + physics: physics, + velocity: _horizontal.applyMomentumTo(supported.dx), + axis: .horizontal, + viewportDimension: viewportSize.width, + devicePixelRatio: devicePixelRatio, + ); + _vertical.startBallistic( + physics: physics, + velocity: _vertical.applyMomentumTo(supported.dy), + axis: .vertical, + viewportDimension: viewportSize.height, + devicePixelRatio: devicePixelRatio, + ); + return !done; + } + + Offset update(Offset delta, Duration? timeStamp) { + final supported = _supported(delta); + return Offset( + _horizontal.update(supported.dx, timeStamp), + _vertical.update(supported.dy, timeStamp), + ); + } + + Offset _supported(Offset delta) { + return target.reportMouse ? delta : Offset(0, delta.dy); + } + + static double _seconds(Duration elapsed) { + return elapsed.inMicroseconds / Duration.microsecondsPerSecond; + } +} + +/// Accumulates sub-cell motion for one compatible terminal scroll target. +/// +/// Mouse-reporting remainders are tied to their cell and modifier snapshot; +/// alternate-scroll remainders can continue across positions because only +/// vertical key steps are emitted. +final class _ScrollRemainder { + final CellMetrics metrics; + final _ScrollTarget target; + double horizontal; + double vertical; + + _ScrollRemainder(this.target, this.metrics) : horizontal = 0, vertical = 0; + + bool shares(_ScrollTarget other, CellMetrics otherMetrics) { + if (metrics != otherMetrics || target.reportMouse != other.reportMouse) { + return false; + } + if (!other.reportMouse) return true; + return target.mods == other.mods && + metrics.cellAt(target.position) == metrics.cellAt(other.position); + } +} + +/// Per-axis motion state matching Flutter's scroll-drag momentum behavior. +final class _ScrollAxis { + /// Finite synthetic extents let Flutter create a ballistic simulation even + /// though the terminal routes the resulting deltas rather than using a + /// Flutter scroll position. + static const _simulationExtent = 1e9; + + /// Prevents zero-sized test or detached surfaces from producing invalid + /// scroll metrics for the simulation. + static const _minimumViewportDimension = 1.0; + + /// Keeps fallback scroll metrics valid when a platform reports no scale. + static const _minimumDevicePixelRatio = 1.0; + + static const _largeThresholdBreakDistance = 24.0; + static const _motionStoppedThreshold = Duration(milliseconds: 50); + + final double carriedVelocity; + final double? motionStartDistanceThreshold; + double? _distanceSinceStop; + Duration? _lastMovement; + bool _retainsMomentum; + Simulation? _simulation; + + _ScrollAxis({ + required this.carriedVelocity, + required this.motionStartDistanceThreshold, + required Duration? timeStamp, + }) : _lastMovement = timeStamp, + _distanceSinceStop = motionStartDistanceThreshold == null ? null : 0, + _retainsMomentum = carriedVelocity != 0; + + bool get done => _simulation == null; + + double update(double delta, Duration? timeStamp) { + if (delta != 0) _lastMovement = timeStamp; + _updateMomentum(delta, timeStamp); + return _applyMotionStartThreshold(delta, timeStamp); + } + + void dropMomentum() => _retainsMomentum = false; + + double positionAt(double time, double fallback) { + final simulation = _simulation; + if (simulation == null) return fallback; + final position = simulation.x(time); + if (simulation.isDone(time)) _simulation = null; + return position; + } + + void startBallistic({ + required ScrollPhysics physics, + required double velocity, + required Axis axis, + required double viewportDimension, + required double devicePixelRatio, + }) { + _simulation = velocity == 0 + ? null + : physics.createBallisticSimulation( + FixedScrollMetrics( + pixels: 0, + maxScrollExtent: _simulationExtent, + minScrollExtent: -_simulationExtent, + axisDirection: axis == .horizontal ? .right : .down, + devicePixelRatio: devicePixelRatio > 0 + ? devicePixelRatio + : _minimumDevicePixelRatio, + viewportDimension: viewportDimension > 0 + ? viewportDimension + : _minimumViewportDimension, + ), + velocity, + ); + } + + double velocityAt(double time) { + final simulation = _simulation; + if (simulation == null || simulation.isDone(time)) return 0; + return simulation.dx(time); + } + + double applyMomentumTo(double replacement) { + if (!_retainsMomentum || + replacement.sign != carriedVelocity.sign || + replacement.abs() <= + carriedVelocity.abs() * + ScrollDragController.momentumRetainVelocityThresholdFactor) { + return replacement; + } + return replacement + carriedVelocity; + } + + double _applyMotionStartThreshold(double delta, Duration? timeStamp) { + final threshold = motionStartDistanceThreshold; + if (timeStamp == null || threshold == null) return delta; + if (delta == 0) { + final lastMovement = _lastMovement; + if (_distanceSinceStop == null && + (lastMovement == null || + timeStamp - lastMovement > _motionStoppedThreshold)) { + _distanceSinceStop = 0; + } + return 0; + } + final distance = _distanceSinceStop; + if (distance == null) return delta; + _distanceSinceStop = distance + delta; + if (_distanceSinceStop!.abs() <= threshold) return 0; + _distanceSinceStop = null; + if (delta.abs() > _largeThresholdBreakDistance) return delta; + final easedDistance = threshold / 3; + return (delta.abs() < easedDistance ? delta.abs() : easedDistance) * + delta.sign; + } + + void _updateMomentum(double delta, Duration? timeStamp) { + if (!_retainsMomentum || delta != 0) return; + final lastMovement = _lastMovement; + if (timeStamp == null || + lastMovement == null || + timeStamp - lastMovement > + ScrollDragController.momentumRetainStationaryDurationThreshold) { + _retainsMomentum = false; + } + } +} + +final class _TerminalPanGestureRecognizer extends PanGestureRecognizer + with _TerminalScrollSequence { + _TerminalPanGestureRecognizer({super.debugOwner}) + : super(supportedDevices: const {.touch, .trackpad}); + + @override + void addAllowedPointer(PointerDownEvent event) { + startSequence(event); + super.addAllowedPointer(event); + } + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + startSequence(event); + super.addAllowedPointerPanZoom(event); + } + + @override + bool isPointerAllowed(PointerEvent event) { + return allowsSequence(event) && super.isPointerAllowed(event); + } + + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { + return allowsSequence(event) && super.isPointerPanZoomAllowed(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + super.didStopTrackingLastPointer(pointer); + stopSequence(); + } +} + +final class _TerminalVerticalDragGestureRecognizer + extends VerticalDragGestureRecognizer + with _TerminalScrollSequence { + _TerminalVerticalDragGestureRecognizer({super.debugOwner}) + : super(supportedDevices: const {.touch, .trackpad}); + + @override + void addAllowedPointer(PointerDownEvent event) { + startSequence(event); + super.addAllowedPointer(event); + } + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + startSequence(event); + super.addAllowedPointerPanZoom(event); + } + + @override + bool isPointerAllowed(PointerEvent event) { + return allowsSequence(event) && super.isPointerAllowed(event); + } + + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { + return allowsSequence(event) && super.isPointerPanZoomAllowed(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + super.didStopTrackingLastPointer(pointer); + stopSequence(); + } +} + +/// Keeps one recognizer eligible for the full pointer sequence it accepted. +/// +/// Eligibility is sampled only at sequence start. This prevents modifier or +/// terminal-mode changes from transferring an in-flight sequence between the +/// pan and vertical recognizers. +mixin _TerminalScrollSequence { + late ValueGetter _canStart; + late ValueChanged _onPointerStart; + PointerDeviceKind? _activeKind; + + void configureSequence({ + required ValueGetter canStart, + required ValueChanged onPointerStart, + }) { + _canStart = canStart; + _onPointerStart = onPointerStart; + } + + bool allowsSequence(PointerEvent event) { + final activeKind = _activeKind; + return activeKind == null ? _canStart() : activeKind == event.kind; + } + + void startSequence(PointerEvent event) { + if (_activeKind != null) return; + _activeKind = event.kind; + _onPointerStart(event); + } + + void stopSequence() => _activeKind = null; +} diff --git a/packages/flterm/lib/src/widgets/selection_gesture_driver.dart b/packages/flterm/lib/src/interaction/selection_gesture_driver.dart similarity index 67% rename from packages/flterm/lib/src/widgets/selection_gesture_driver.dart rename to packages/flterm/lib/src/interaction/selection_gesture_driver.dart index 1dd17743..bebf76af 100644 --- a/packages/flterm/lib/src/widgets/selection_gesture_driver.dart +++ b/packages/flterm/lib/src/interaction/selection_gesture_driver.dart @@ -1,5 +1,3 @@ -import 'package:flutter/gestures.dart' show kDoubleTapSlop, kDoubleTapTimeout; -import 'package:flutter/widgets.dart' show Offset; import 'package:libghostty/libghostty.dart' show GridRef, @@ -7,13 +5,17 @@ import 'package:libghostty/libghostty.dart' Selection, SelectionGesture, SelectionGestureBehavior, + SelectionGestureBehaviors, SelectionGestureEvent, SelectionGestureGeometry, Terminal; import 'package:meta/meta.dart'; -import '../foundation.dart'; - +/// Owns reusable terminal selection gesture events for one terminal. +/// +/// This is the native-resource boundary beneath the terminal selection owner. +/// It keeps gesture continuation and word-boundary state together while +/// avoiding a new native event allocation for every pointer update. @internal final class SelectionGestureDriver { final SelectionGesture _gesture; @@ -21,7 +23,6 @@ final class SelectionGestureDriver { final SelectionGestureEvent _press = .press(); final SelectionGestureEvent _release = .release(); final SelectionGestureEvent _autoscroll = .autoscrollTick(); - final _pressClock = Stopwatch()..start(); List? _wordBoundaryCodepoints; SelectionGestureDriver(Terminal terminal) @@ -31,13 +32,14 @@ final class SelectionGestureDriver { Selection? autoscroll({ required Position cell, - required Offset localPosition, + required double pixelX, + required double pixelY, required bool rectangle, required SelectionGestureGeometry geometry, }) { _autoscroll ..setViewport(cell) - ..setPosition(localPosition.dx, localPosition.dy) + ..setPosition(pixelX, pixelY) ..setRectangle(value: rectangle) ..setGeometry(geometry); _setWordBoundaryCodepoints(_autoscroll); @@ -54,13 +56,14 @@ final class SelectionGestureDriver { Selection? drag({ required GridRef ref, - required Offset localPosition, + required double pixelX, + required double pixelY, required bool rectangle, required SelectionGestureGeometry geometry, }) { _drag ..setRef(ref) - ..setPosition(localPosition.dx, localPosition.dy) + ..setPosition(pixelX, pixelY) ..setRectangle(value: rectangle) ..setGeometry(geometry); _setWordBoundaryCodepoints(_drag); @@ -69,19 +72,22 @@ final class SelectionGestureDriver { Selection? press({ required GridRef ref, - required Offset localPosition, - required TerminalGestureSettings settings, + required double pixelX, + required double pixelY, + required SelectionGestureBehaviors behaviors, + required String? wordBoundaries, + required double repeatDistance, + required Duration repeatInterval, + required Duration timeStamp, }) { - _wordBoundaryCodepoints = settings.wordBoundaries?.runes.toList( - growable: false, - ); + _wordBoundaryCodepoints = wordBoundaries?.runes.toList(growable: false); _press ..setRef(ref) - ..setPosition(localPosition.dx, localPosition.dy) - ..setBehaviors(settings.selectionBehaviors) - ..setRepeatDistance(kDoubleTapSlop) - ..setRepeatIntervalNs(kDoubleTapTimeout.inMicroseconds * 1000) - ..setTimeNs(_pressClock.elapsedMicroseconds * 1000); + ..setPosition(pixelX, pixelY) + ..setBehaviors(behaviors) + ..setRepeatDistance(repeatDistance) + ..setRepeatIntervalNs(repeatInterval.inMicroseconds * 1000) + ..setTimeNs(timeStamp.inMicroseconds * 1000); _setWordBoundaryCodepoints(_press); return _gesture.apply(_press); } diff --git a/packages/flterm/lib/src/interaction/terminal_selection.dart b/packages/flterm/lib/src/interaction/terminal_selection.dart new file mode 100644 index 00000000..ac77cb3a --- /dev/null +++ b/packages/flterm/lib/src/interaction/terminal_selection.dart @@ -0,0 +1,356 @@ +import 'package:libghostty/libghostty.dart' hide TerminalGeometry; +import 'package:meta/meta.dart'; + +import '../foundation/terminal_geometry.dart'; +import 'selection_gesture_driver.dart'; + +/// Owns terminal selection state, measured bounds, and gesture continuation. +/// +/// It converts normalized view input into terminal grid references, clamps +/// interactions to committed geometry, and emits one controller notification +/// when the effective selection changes. Gesture continuation is delegated to +/// [SelectionGestureDriver], while this owner remains responsible for storing +/// the resulting selection on the terminal and suppressing equivalent updates. +final class TerminalSelection { + final void Function() _notifyChanged; + final Terminal _terminal; + late final SelectionGestureDriver _gesture; + var _cellHeight = 0.0; + var _cellWidth = 0.0; + var _columns = 0; + var _rows = 0; + + TerminalSelection(this._terminal, this._notifyChanged) { + _gesture = SelectionGestureDriver(_terminal); + } + + bool get hasSelection => _terminal.selection != null; + + void cancelGesture() { + _gesture.reset(); + _set(null, clearIfNull: true); + } + + void clear({required bool notify}) { + if (_terminal.selection == null) return; + _gesture.reset(); + _terminal.selection = null; + if (notify) _notifyChanged(); + } + + void dispose() => _gesture.dispose(); + + bool extend(Key key) { + final SelectionAdjust? adjustment = switch (key) { + .arrowRight => .right, + .arrowLeft => .left, + .arrowUp => .up, + .arrowDown => .down, + _ => null, + }; + if (adjustment == null) return false; + final selection = _terminal.selection; + if (selection == null) return false; + _set(selection.adjust(adjustment)); + return true; + } + + void handleAutoscroll(TerminalSelectionAutoscrollEvent event) { + if (_columns <= 0 || _rows <= 0) return; + _set( + _gesture.autoscroll( + cell: _clampViewportPoint(event.cell), + pixelX: event.pixelX, + pixelY: event.pixelY, + rectangle: event.rectangle, + geometry: _gestureGeometry(), + ), + ); + } + + void handleDrag(TerminalSelectionDragEvent event) { + final ref = _viewportRef(event.cell); + if (ref == null) return; + _set( + _gesture.drag( + ref: ref, + pixelX: event.pixelX, + pixelY: event.pixelY, + rectangle: event.rectangle, + geometry: _gestureGeometry(), + ), + ); + } + + void handlePress(TerminalSelectionPressEvent event) { + final ref = _viewportRef(event.cell); + if (ref == null) { + _set(null, clearIfNull: true); + return; + } + + var selection = _gesture.press( + ref: ref, + pixelX: event.pixelX, + pixelY: event.pixelY, + behaviors: event.behaviors, + wordBoundaries: event.wordBoundaries, + repeatDistance: event.repeatDistance, + repeatInterval: event.repeatInterval, + timeStamp: event.timeStamp, + ); + if (selection != null && + event.fullWidthLine && + _gesture.behavior == .line) { + selection = _fullWidthLine(selection); + } + _set(selection, clearIfNull: true); + } + + void handleRelease(Position cell) { + _set(_gesture.release(_viewportRef(cell))); + } + + void invalidate() => clear(notify: false); + + void selectAll() => _set(_terminal.selectAll()); + + String selectedText({FormatterFormat format = .plain}) { + final selection = _terminal.selection; + if (selection == null) return ''; + return _terminal.formatSelection( + format: format, + unwrap: !selection.rectangle, + selection: selection, + ) ?? + ''; + } + + void selectRange({ + required Position start, + required Position end, + required PointTag pointTag, + required bool rectangle, + }) { + _set( + .fromRefs( + start: .at(_terminal, start, pointTag: pointTag), + end: .at(_terminal, end, pointTag: pointTag), + rectangle: rectangle, + ), + ); + } + + void updateGeometry(TerminalGeometry geometry) { + _columns = geometry.cols; + _rows = geometry.rows; + _cellWidth = geometry.cellWidth; + _cellHeight = geometry.cellHeight; + } + + Position _clampViewportPoint(Position position) { + return Position( + row: position.row.clamp(0, _rows - 1), + col: position.col.clamp(0, _columns - 1), + ); + } + + void _ensureGridSize() { + if (_rows > 0 && _columns > 0) return; + final geometry = _terminal.geometry; + _rows = geometry.rows; + _columns = geometry.cols; + } + + Selection _fullWidthLine(Selection selection) { + final start = selection.start.positionIn(.viewport); + final end = selection.end.positionIn(.viewport); + if (start == null || end == null) return selection; + _ensureGridSize(); + if (_columns <= 0) return selection; + return Selection.fromRefs( + start: .at( + _terminal, + Position(row: start.row, col: 0), + pointTag: .viewport, + ), + end: .at( + _terminal, + Position(row: end.row, col: _columns - 1), + pointTag: .viewport, + ), + ); + } + + SelectionGestureGeometry _gestureGeometry() { + _ensureGridSize(); + return SelectionGestureGeometry( + columns: _columns <= 0 ? 1 : _columns, + cellWidth: _cellWidth <= 0 ? 1 : _cellWidth.round(), + paddingLeft: 0, + screenHeight: _cellHeight <= 0 + ? 1 + : (_cellHeight * (_rows <= 0 ? 1 : _rows)).round(), + ); + } + + void _set(Selection? value, {bool clearIfNull = false}) { + if (value == null) { + if (!clearIfNull || _terminal.selection == null) return; + _terminal.selection = null; + _notifyChanged(); + return; + } + + final current = _terminal.selection; + if (current != null && current.equal(value)) return; + _terminal.selection = value; + _notifyChanged(); + } + + GridRef? _viewportRef(Position position) { + _ensureGridSize(); + if (_rows <= 0 || _columns <= 0) return null; + return .at(_terminal, _clampViewportPoint(position), pointTag: .viewport); + } +} + +/// A normalized terminal selection autoscroll update. +@immutable +final class TerminalSelectionAutoscrollEvent { + /// The viewport cell under the pointer. + final Position cell; + + /// The pointer's logical horizontal position. + final double pixelX; + + /// The pointer's logical vertical position. + final double pixelY; + + /// Whether the selection is rectangular. + final bool rectangle; + + const TerminalSelectionAutoscrollEvent({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.rectangle, + }); + + @override + int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); + + @override + bool operator ==(Object other) { + return other is TerminalSelectionAutoscrollEvent && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.rectangle == rectangle; + } +} + +/// A normalized terminal selection drag. +@immutable +final class TerminalSelectionDragEvent { + /// The viewport cell under the pointer. + final Position cell; + + /// The pointer's logical horizontal position. + final double pixelX; + + /// The pointer's logical vertical position. + final double pixelY; + + /// Whether the selection is rectangular. + final bool rectangle; + + const TerminalSelectionDragEvent({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.rectangle, + }); + + @override + int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); + + @override + bool operator ==(Object other) { + return other is TerminalSelectionDragEvent && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.rectangle == rectangle; + } +} + +/// A normalized terminal selection press. +@immutable +final class TerminalSelectionPressEvent { + /// The viewport cell under the pointer. + final Position cell; + + /// The pointer's logical horizontal position. + final double pixelX; + + /// The pointer's logical vertical position. + final double pixelY; + + /// Selection behavior for single-, double-, and triple-clicks. + final SelectionGestureBehaviors behaviors; + + /// Characters treated as word boundaries, or null for the default. + final String? wordBoundaries; + + /// Maximum distance between repeated clicks. + final double repeatDistance; + + /// Maximum interval between repeated clicks. + final Duration repeatInterval; + + /// Timestamp supplied by the pointer event source. + final Duration timeStamp; + + /// Whether a line selection expands to the full terminal width. + final bool fullWidthLine; + + const TerminalSelectionPressEvent({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.behaviors, + required this.wordBoundaries, + required this.repeatDistance, + required this.repeatInterval, + required this.timeStamp, + required this.fullWidthLine, + }); + + @override + int get hashCode => Object.hash( + cell, + pixelX, + pixelY, + behaviors, + wordBoundaries, + repeatDistance, + repeatInterval, + timeStamp, + fullWidthLine, + ); + + @override + bool operator ==(Object other) { + return other is TerminalSelectionPressEvent && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.behaviors == behaviors && + other.wordBoundaries == wordBoundaries && + other.repeatDistance == repeatDistance && + other.repeatInterval == repeatInterval && + other.timeStamp == timeStamp && + other.fullWidthLine == fullWidthLine; + } +} diff --git a/packages/flterm/lib/src/widgets/link_interaction.dart b/packages/flterm/lib/src/links/link_interaction.dart similarity index 79% rename from packages/flterm/lib/src/widgets/link_interaction.dart rename to packages/flterm/lib/src/links/link_interaction.dart index ddce61b0..ae2d13fc 100644 --- a/packages/flterm/lib/src/widgets/link_interaction.dart +++ b/packages/flterm/lib/src/links/link_interaction.dart @@ -4,29 +4,29 @@ import 'package:flutter/widgets.dart' show Offset; import 'package:libghostty/libghostty.dart' show Mods, Position, Terminal; import '../foundation.dart'; -import '../links/activation_policy.dart'; -import '../links/link_resolver.dart'; -import '../links/link_settings.dart'; -import '../links/link_snapshot.dart'; +import 'activation_policy.dart'; +import 'link_resolver.dart'; +import 'link_settings.dart'; +import 'link_snapshot.dart'; /// Immutable inputs needed to resolve links for one terminal viewport. @internal @immutable final class LinkContext { - final Terminal terminal; - final int rows; final int cols; + final int rows; final String? cwd; + final Terminal terminal; const LinkContext({ - required this.terminal, - required this.rows, required this.cols, required this.cwd, + required this.rows, + required this.terminal, }); @override - int get hashCode => Object.hash(identityHashCode(terminal), rows, cols, cwd); + int get hashCode => Object.hash(cols, cwd, rows, identityHashCode(terminal)); bool get hasViewport => rows > 0 && cols > 0; @@ -34,27 +34,32 @@ final class LinkContext { bool operator ==(Object other) => identical(this, other) || other is LinkContext && - identical(terminal, other.terminal) && - rows == other.rows && cols == other.cols && - cwd == other.cwd; + cwd == other.cwd && + rows == other.rows && + identical(terminal, other.terminal); } -/// Coordinates link hover, press, callbacks, and render snapshots. +/// Owns link detection, render snapshots, and pointer activation for one view. +/// +/// Detection is lazy and cached until terminal content, viewport context, or +/// matching settings change. Pointer state stays here so presses and releases +/// resolve against the same detected link. Hover-only style changes invalidate +/// renderer snapshots without rebuilding link matches; content or matching +/// changes invalidate both caches and any in-flight activation candidate. @internal -final class LinkInteraction { +final class LinkInteraction extends ChangeNotifier { final LinkResolver _resolver; LinkContext? _context; - var _settings = const LinkSettings(); + CellRange? _highlighted; var _idleStyle = const HyperlinkStyle(); LinkSnapshot? _idleSnapshot; - LinkSnapshot? _snapshot; - - Offset? _lastHoverPosition; Position? _lastHoverCell; - CellRange? _highlighted; + Offset? _lastHoverPosition; _LinkPressCandidate? _pressCandidate; + var _settings = const LinkSettings(); + LinkSnapshot? _snapshot; LinkInteraction({LinkResolver? resolver}) : _resolver = resolver ?? LinkResolver(); @@ -62,11 +67,17 @@ final class LinkInteraction { CellRange? get highlighted => _highlighted; /// Clears hover and press state without invalidating detected links. - void cancel() => _clearInteraction(); + void cancel() { + final changed = _highlighted != null || _pressCandidate != null; + _clearInteraction(); + if (changed) notifyListeners(); + } void cancelHover() { + final previous = _highlighted; _lastHoverPosition = null; _clearHoverHit(); + if (previous != _highlighted) notifyListeners(); } CellRange? handleHover({ @@ -74,8 +85,15 @@ final class LinkInteraction { required CellMetrics metrics, required Mods virtualMods, }) { + final previous = _highlighted; _lastHoverPosition = localPosition; - return _hoverAt(localPosition, metrics: metrics, virtualMods: virtualMods); + final next = _hoverAt( + localPosition, + metrics: metrics, + virtualMods: virtualMods, + ); + if (previous != next) notifyListeners(); + return next; } bool handlePress({ @@ -110,7 +128,8 @@ final class LinkInteraction { void invalidateContent() { _idleSnapshot = null; _snapshot = null; - cancelHover(); + _lastHoverPosition = null; + _clearHoverHit(); } CellRange? refreshHover({ @@ -119,7 +138,10 @@ final class LinkInteraction { }) { final position = _lastHoverPosition; if (position == null) return _highlighted; - return _hoverAt(position, metrics: metrics, virtualMods: virtualMods); + final previous = _highlighted; + final next = _hoverAt(position, metrics: metrics, virtualMods: virtualMods); + if (previous != next) notifyListeners(); + return next; } /// Returns the current renderer snapshot, rebuilding it when needed. @@ -159,7 +181,9 @@ final class LinkInteraction { if (contextChanged || matchSettingsChanged) { _idleSnapshot = null; _snapshot = null; - _clearInteraction(); + _cancelPress(); + _lastHoverPosition = null; + _clearHoverHit(); return; } @@ -167,7 +191,11 @@ final class LinkInteraction { _idleSnapshot = null; _snapshot = null; } - if (gestureSettingsChanged) _clearInteraction(); + if (gestureSettingsChanged) { + _cancelPress(); + _lastHoverPosition = null; + _clearHoverHit(); + } } LinkSnapshot _buildSnapshot(LinkContext context) { @@ -198,7 +226,8 @@ final class LinkInteraction { void _clearInteraction() { _cancelPress(); - cancelHover(); + _lastHoverPosition = null; + _clearHoverHit(); } bool _hasIdleVisualEffect() { @@ -207,25 +236,6 @@ final class LinkInteraction { _idleStyle.textColor != null; } - ActivatedLink? _linkAt(Position cell) { - final context = _context; - if (context == null || !context.hasViewport) return null; - if (cell.row < 0 || - cell.row >= context.rows || - cell.col < 0 || - cell.col >= context.cols) { - return null; - } - return _resolver.linkAt( - context.terminal, - cell, - _settings, - rows: context.rows, - cols: context.cols, - cwd: context.cwd, - ); - } - CellRange? _hoverAt( Offset localPosition, { required CellMetrics metrics, @@ -263,6 +273,25 @@ final class LinkInteraction { return snapshot; } + ActivatedLink? _linkAt(Position cell) { + final context = _context; + if (context == null || !context.hasViewport) return null; + if (cell.row < 0 || + cell.row >= context.rows || + cell.col < 0 || + cell.col >= context.cols) { + return null; + } + return _resolver.linkAt( + context.terminal, + cell, + _settings, + rows: context.rows, + cols: context.cols, + cwd: context.cwd, + ); + } + bool _needsIdleSnapshot() { if (!_hasIdleVisualEffect()) return false; final Set types = _settings.types; diff --git a/packages/flterm/lib/src/rendering.dart b/packages/flterm/lib/src/rendering.dart index 38932873..b9375c78 100644 --- a/packages/flterm/lib/src/rendering.dart +++ b/packages/flterm/lib/src/rendering.dart @@ -1,4 +1,5 @@ export 'rendering/font/font_data_resolver.dart'; export 'rendering/font/font_table_metrics.dart'; export 'rendering/font/measure_cell_metrics.dart'; +export 'rendering/terminal_frame_source.dart'; export 'rendering/terminal_renderer.dart'; diff --git a/packages/flterm/lib/src/rendering/kitty_png_decoder.dart b/packages/flterm/lib/src/rendering/kitty_png_decoder.dart deleted file mode 100644 index 599302de..00000000 --- a/packages/flterm/lib/src/rendering/kitty_png_decoder.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:typed_data'; - -import 'package:image/image.dart' as img; -import 'package:libghostty/libghostty.dart'; - -var _installed = false; - -/// Installs flterm's default PNG decoder for Kitty graphics on first -/// call. Idempotent so every [TerminalController] can call it on -/// construction regardless of how many others already exist. -void installDefaultKittyPngDecoder() { - if (_installed) return; - _installed = true; - LibGhostty.setPngDecoder(_decodePng); -} - -DecodedImage? _decodePng(Uint8List bytes) { - final decoded = img.decodePng(bytes); - if (decoded == null) return null; - final rgba = decoded.convert(format: img.Format.uint8, numChannels: 4); - return ( - width: rgba.width, - height: rgba.height, - rgba: Uint8List.fromList(rgba.toUint8List()), - ); -} diff --git a/packages/flterm/lib/src/rendering/terminal_frame_source.dart b/packages/flterm/lib/src/rendering/terminal_frame_source.dart new file mode 100644 index 00000000..d2cc82b9 --- /dev/null +++ b/packages/flterm/lib/src/rendering/terminal_frame_source.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:libghostty/libghostty.dart' hide Listenable; + +/// Merges terminal and viewport invalidation into one renderer listenable. +/// +/// The source owns only listener registration; it does not cache terminal +/// state or prepare frames. This keeps [TerminalRenderBox] subscribed to one +/// lifecycle-bound object while preserving synchronous invalidation ordering. +@internal +final class TerminalFrameSource extends ChangeNotifier { + final Terminal terminal; + final Listenable? _viewportChanges; + + TerminalFrameSource(this.terminal, {Listenable? viewportChanges}) + : _viewportChanges = viewportChanges { + terminal.addListener(_handleChanged); + viewportChanges?.addListener(_handleChanged); + } + + @override + void dispose() { + terminal.removeListener(_handleChanged); + _viewportChanges?.removeListener(_handleChanged); + super.dispose(); + } + + void _handleChanged() => notifyListeners(); +} diff --git a/packages/flterm/lib/src/rendering/terminal_renderer.dart b/packages/flterm/lib/src/rendering/terminal_renderer.dart index 93d08820..a9e5949b 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -7,6 +7,7 @@ import '../foundation.dart'; import '../links/link_snapshot.dart'; import 'atlas/atlas_config.dart'; import 'paint_state.dart'; +import 'terminal_frame_source.dart'; import 'terminal_render_cache.dart'; import 'terminal_render_pipeline.dart'; @@ -14,52 +15,60 @@ import 'terminal_render_pipeline.dart'; /// and selection overlays. /// /// This is the core rendering widget used internally by [TerminalView]. -/// It owns a [TerminalRenderBox] that orchestrates layout (grid sizing, -/// terminal resize), frame sync, and a paint stack. +/// It owns a [TerminalRenderBox] that orchestrates grid measurement, geometry +/// intent reporting, frame sync, and a paint stack. The controller, not the +/// renderer, validates and commits resize intents to the terminal engine. /// /// Sizing is determined by the parent constraints and cell metrics: the /// widget computes how many columns and rows fit, then sizes itself to -/// exactly that grid. When the grid dimensions change, the terminal is -/// resized and [onResize] fires. +/// exactly that grid. When the grid, physical cell dimensions, or surface +/// padding change, [onGeometryChanged] reports the geometry intent to the +/// owner. /// /// ```dart /// TerminalRenderer( -/// terminal: myTerminal, +/// frameSource: frameSource, /// theme: TerminalTheme.dark(), /// metrics: measureCellMetrics(fontFamily: 'monospace', fontSize: 14), /// offset: ViewportOffset.zero(), -/// renderObserver: controller, +/// focused: true, /// ) /// ``` @internal -class TerminalRenderer extends LeafRenderObjectWidget { - /// The terminal whose screen is rendered. - final Terminal terminal; +final class TerminalRenderer extends LeafRenderObjectWidget { + /// Supplies the terminal and publishes frame and viewport changes. + final TerminalFrameSource frameSource; /// Visual style applied to the terminal. /// - /// When changed, theme colors are pushed to the terminal (foreground, - /// background, palette, cursor color), the glyph atlas is updated if - /// font properties changed, and a full repaint is scheduled. + /// When changed, the glyph atlas is updated if font properties changed and + /// a full repaint is scheduled. The owning view applies terminal colors. final TerminalTheme theme; /// Cell pixel dimensions used for grid sizing and coordinate conversion. /// /// When changed, the glyph atlas is cleared and layout is recalculated. - /// A grid dimension change triggers terminal resize and [onResize]. + /// A geometry change triggers [onGeometryChanged]. final CellMetrics metrics; + /// Padding around the rendered terminal surface in logical pixels. + /// + /// This is carried to the resize callback so surface-space mouse + /// coordinates can be converted consistently with the terminal engine's + /// physical surface size. + final EdgeInsets surfacePadding; + /// Scroll offset provided by a [Scrollable] ancestor. /// /// At `pixels == 0`, the oldest scrollback row is visible. /// At `pixels == maxScrollExtent`, the live screen is visible. final ViewportOffset offset; - /// Observable focus state. + /// Whether the terminal view currently has focus. /// - /// Listened to by the render box. Changes trigger a repaint to update - /// cursor appearance (filled vs hollow). - final TerminalRenderObserver renderObserver; + /// The owning view supplies this value from its [FocusNode]. Changes + /// trigger a repaint to update cursor appearance. + final bool focused; /// Whether the cursor blink is currently in the visible phase. /// @@ -73,34 +82,36 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// Visible link styling state prepared by the view layer. final LinkSnapshot linkSnapshot; - /// Called when the terminal grid dimensions change during layout. + /// Reports terminal geometry changes discovered during layout. /// - /// Fires after the terminal has been resized. Use this to notify the - /// backend (PTY, SSH) of the new dimensions. - final OnResize? onResize; + /// The callback receives the complete measured geometry. The owner must + /// apply the transaction before notifying its backend. + final ValueChanged onGeometryChanged; + + /// Device pixel ratio of the Flutter view hosting this renderer. + final double devicePixelRatio; /// Internal render cache used to share compatible atlas state. final TerminalRenderCache renderCache; - /// Reports viewport movement that bypasses [Terminal] listeners. - /// - /// Scrolling may change [Terminal.compressionActivity] by making previously - /// visible scrollback eligible for compression. - final VoidCallback? onViewportChanged; + /// Requests a terminal viewport row derived from Flutter scroll layout. + final ValueChanged onViewportRowChanged; const TerminalRenderer({ super.key, - required this.terminal, + required this.frameSource, required this.theme, required this.metrics, + this.surfacePadding = EdgeInsets.zero, required this.offset, - required this.renderObserver, + required this.focused, required this.renderCache, + this.devicePixelRatio = 1, this.blinkVisible = true, this.preeditText = '', this.linkSnapshot = .empty, - this.onResize, - this.onViewportChanged, + required this.onGeometryChanged, + required this.onViewportRowChanged, }); @override @@ -109,14 +120,16 @@ class TerminalRenderer extends LeafRenderObjectWidget { theme: theme, offset: offset, metrics: metrics, - terminal: terminal, + surfacePadding: surfacePadding, + frameSource: frameSource, renderCache: renderCache, - onResize: onResize, - onViewportChanged: onViewportChanged, + devicePixelRatio: devicePixelRatio, + onGeometryChanged: onGeometryChanged, + onViewportRowChanged: onViewportRowChanged, blinkVisible: blinkVisible, preeditText: preeditText, linkSnapshot: linkSnapshot, - renderObserver: renderObserver, + focused: focused, ); } @@ -124,7 +137,7 @@ class TerminalRenderer extends LeafRenderObjectWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty('terminal', terminal)) + ..add(DiagnosticsProperty('terminal', frameSource.terminal)) ..add(DiagnosticsProperty('theme', theme)) ..add(DiagnosticsProperty('metrics', metrics)) ..add(DiagnosticsProperty('offset', offset)) @@ -144,14 +157,16 @@ class TerminalRenderer extends LeafRenderObjectWidget { TerminalRenderBox renderObject, ) { renderObject - ..terminal = terminal + ..frameSource = frameSource ..theme = theme ..renderCache = renderCache ..offset = offset ..metrics = metrics - ..onResize = onResize - ..onViewportChanged = onViewportChanged - ..renderObserver = renderObserver + ..surfacePadding = surfacePadding + ..devicePixelRatio = devicePixelRatio + ..onGeometryChanged = onGeometryChanged + ..onViewportRowChanged = onViewportRowChanged + ..focused = focused ..blinkVisible = blinkVisible ..preeditText = preeditText ..linkSnapshot = linkSnapshot; @@ -163,8 +178,8 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// Three phases per frame: /// /// 1. **Layout**: computes grid size from constraints and [CellMetrics], -/// configures the glyph atlas for the current DPR, resizes the terminal -/// if the grid changed, and updates scroll extents. +/// configures the glyph atlas for the current DPR, reports geometry intent +/// when measurements change, and updates scroll extents. /// /// 2. **Sync** (start of paint): snapshots terminal cells, resolves colors /// (including OSC 10/11 overrides, bold-is-bright, inverse, faint), @@ -176,44 +191,58 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// /// Created and managed by [TerminalRenderer]. Not intended for direct use. @internal -class TerminalRenderBox extends RenderBox { - Terminal _terminal; - ViewportOffset _offset; - TerminalRenderObserver _renderObserver; - OnResize? _onResize; - VoidCallback? _onViewportChanged; - TerminalRenderCache _renderCache; +final class TerminalRenderBox extends RenderBox { + final TerminalPaintState _paintState; + late final TerminalRenderPipeline _pipeline; + + var _applyingViewportIntent = false; + var _cellHeightPx = 0; + var _cellWidthPx = 0; + double _devicePixelRatio; + TerminalFrameSource _frameSource; late TerminalAtlasHandle _atlasHandle; - var _performingLayout = false; - var _needsFrameSync = false; - var _stickToBottom = true; + var _lastCellHeight = 0.0; + var _lastCellWidth = 0.0; + var _lastDevicePixelRatio = 0.0; var _lastScrollbackRows = 0; - var _preeditText = ''; + var _lastSurfacePadding = EdgeInsets.zero; LinkSnapshot _linkSnapshot; - - final TerminalPaintState _paintState; - late final TerminalRenderPipeline _pipeline; + var _needsFrameSync = false; + ViewportOffset _offset; + ValueChanged _onGeometryChanged; + ValueChanged _onViewportRowChanged; + int? _pendingViewportRow; + var _performingLayout = false; + var _preeditText = ''; + bool? _primaryStickToBottom; + TerminalRenderCache _renderCache; + var _stickToBottom = true; + var _surfacePadding = EdgeInsets.zero; TerminalRenderBox({ - required this._terminal, + required this._frameSource, required TerminalTheme theme, required CellMetrics metrics, + EdgeInsets surfacePadding = EdgeInsets.zero, required this._offset, - required this._renderObserver, + required bool focused, required this._renderCache, + required this._devicePixelRatio, bool blinkVisible = true, this._linkSnapshot = .empty, this._preeditText = '', - this._onResize, - this._onViewportChanged, - }) : _paintState = TerminalPaintState(theme, metrics) + required this._onGeometryChanged, + required this._onViewportRowChanged, + }) : _surfacePadding = surfacePadding, + _lastSurfacePadding = surfacePadding, + _paintState = TerminalPaintState(theme, metrics) ..blinkVisible = blinkVisible - ..cursorFocused = _renderObserver.hasFocus { + ..cursorFocused = focused { _atlasHandle = _renderCache.acquireAtlas( .fromTheme( theme: theme, metrics: metrics, - devicePixelRatio: _currentDevicePixelRatio, + devicePixelRatio: _devicePixelRatio, ), ); final atlas = _atlasHandle.atlas; @@ -222,8 +251,14 @@ class TerminalRenderBox extends RenderBox { state: _paintState, onImageReady: markNeedsPaint, ); + } + + Terminal get _terminal => _frameSource.terminal; - _applyTerminalThemeColors(); + set surfacePadding(EdgeInsets value) { + if (_surfacePadding == value) return; + _surfacePadding = value; + markNeedsLayout(); } bool get blinkVisible => _paintState.blinkVisible; @@ -315,16 +350,25 @@ class TerminalRenderBox extends RenderBox { markNeedsLayout(); } - set onResize(OnResize? value) => _onResize = value; + set onGeometryChanged(ValueChanged value) => + _onGeometryChanged = value; - set onViewportChanged(VoidCallback? value) => _onViewportChanged = value; + set devicePixelRatio(double value) { + if (_devicePixelRatio == value) return; + _devicePixelRatio = value; + markNeedsLayout(); + } + + set onViewportRowChanged(ValueChanged value) => + _onViewportRowChanged = value; - set renderObserver(TerminalRenderObserver value) { - if (_renderObserver == value) return; - if (attached) _renderObserver.removeListener(_onRenderObserverChanged); - _renderObserver = value; - if (attached) _renderObserver.addListener(_onRenderObserverChanged); - _onRenderObserverChanged(); + bool get focused => _paintState.cursorFocused; + + set focused(bool value) { + if (_paintState.cursorFocused == value) return; + _paintState.cursorFocused = value; + _pipeline.refreshCursorGlyph(); + markNeedsPaint(); } set renderCache(TerminalRenderCache value) { @@ -335,12 +379,19 @@ class TerminalRenderBox extends RenderBox { if (atlasChanged) _markFrameDirty(); } - set terminal(Terminal value) { - if (_terminal == value) return; - if (attached) _terminal.removeListener(_onTerminalChanged); - _terminal = value; - if (attached) _terminal.addListener(_onTerminalChanged); - _applyTerminalThemeColors(); + set frameSource(TerminalFrameSource value) { + if (identical(_frameSource, value)) return; + if (attached) _frameSource.removeListener(_onFrameChanged); + final terminalChanged = !identical(_terminal, value.terminal); + _frameSource = value; + if (attached) _frameSource.addListener(_onFrameChanged); + if (terminalChanged) { + _stickToBottom = true; + _primaryStickToBottom = null; + _cellWidthPx = 0; + _cellHeightPx = 0; + _pendingViewportRow = null; + } _needsFrameSync = true; markNeedsLayout(); } @@ -362,7 +413,6 @@ class TerminalRenderBox extends RenderBox { oldTheme.fontFamily != value.fontFamily || !_listEquals(oldTheme.fontFamilyFallback, value.fontFamilyFallback); _paintState.updateTheme(value); - _applyTerminalThemeColors(); _pipeline.markAllRowsDirty(); _needsFrameSync = true; @@ -377,8 +427,7 @@ class TerminalRenderBox extends RenderBox { void attach(PipelineOwner owner) { super.attach(owner); _offset.addListener(_onScroll); - _renderObserver.addListener(_onRenderObserverChanged); - _terminal.addListener(_onTerminalChanged); + _frameSource.addListener(_onFrameChanged); markNeedsLayout(); } @@ -396,20 +445,13 @@ class TerminalRenderBox extends RenderBox { value: _paintState.blinkVisible, ifTrue: 'cursor visible', ), - ) - ..add( - DiagnosticsProperty( - 'renderObserver', - _renderObserver, - ), ); } @override void detach() { _offset.removeListener(_onScroll); - _renderObserver.removeListener(_onRenderObserverChanged); - _terminal.removeListener(_onTerminalChanged); + _frameSource.removeListener(_onFrameChanged); super.detach(); } @@ -440,73 +482,83 @@ class TerminalRenderBox extends RenderBox { @override void performLayout() { _performingLayout = true; + try { + final maxW = constraints.hasBoundedWidth ? constraints.maxWidth : 0.0; + final maxH = constraints.hasBoundedHeight ? constraints.maxHeight : 0.0; + final (newCols, newRows) = _paintState.metrics.gridSize(maxW, maxH); + + size = constraints.constrain( + Size( + newCols * _paintState.metrics.cellWidth, + newRows * _paintState.metrics.cellHeight, + ), + ); - final maxW = constraints.hasBoundedWidth ? constraints.maxWidth : 0.0; - final maxH = constraints.hasBoundedHeight ? constraints.maxHeight : 0.0; - final (newCols, newRows) = _paintState.metrics.gridSize(maxW, maxH); - - size = constraints.constrain( - Size( - newCols * _paintState.metrics.cellWidth, - newRows * _paintState.metrics.cellHeight, - ), - ); - - final dpr = _currentDevicePixelRatio; - final atlasReconfigured = _acquireAtlasForCurrentConfig(dpr: dpr); - - final gridChanged = - newCols != _paintState.cols || newRows != _paintState.rows; - if (gridChanged) { - _paintState.cols = newCols; - _paintState.rows = newRows; - _paintState.devicePixelRatio = dpr; - if (newCols > 0 && newRows > 0) { - _pipeline.configureGrid(newRows, newCols); - // Cell size is reported in physical pixels so size-report - // escapes and Kitty graphics geometry match a native terminal - // at the same DPI. - _terminal.resize( - cols: newCols, - rows: newRows, - cellWidthPx: (_paintState.metrics.cellWidth * dpr).round(), - cellHeightPx: (_paintState.metrics.cellHeight * dpr).round(), - ); - _onResize?.call(newCols, newRows); + final dpr = _devicePixelRatio; + final atlasReconfigured = _acquireAtlasForCurrentConfig(dpr: dpr); + + final gridChanged = + newCols != _paintState.cols || newRows != _paintState.rows; + final cellWidthPx = (_paintState.metrics.cellWidth * dpr).round(); + final cellHeightPx = (_paintState.metrics.cellHeight * dpr).round(); + final logicalMetricsChanged = + _paintState.metrics.cellWidth != _lastCellWidth || + _paintState.metrics.cellHeight != _lastCellHeight; + final devicePixelRatioChanged = dpr != _lastDevicePixelRatio; + final geometryChanged = + gridChanged || + cellWidthPx != _cellWidthPx || + cellHeightPx != _cellHeightPx || + logicalMetricsChanged || + devicePixelRatioChanged || + _surfacePadding != _lastSurfacePadding; + if (_paintState.devicePixelRatio != dpr) { + _paintState.devicePixelRatio = dpr; } - } else if (_paintState.devicePixelRatio != dpr) { - _paintState.devicePixelRatio = dpr; - } - - _syncScrollLayout(); - - // Grid changes invalidate every row's sprite slot layout. Atlas - // rebinding invalidates atlas references inside the pipeline. - if (gridChanged) _pipeline.markAllRowsDirty(); + if (geometryChanged) { + _paintState.cols = newCols; + _paintState.rows = newRows; + if (newCols > 0 && newRows > 0) { + if (gridChanged) _pipeline.configureGrid(newRows, newCols); + _onGeometryChanged( + TerminalResizeEvent( + cols: newCols, + rows: newRows, + cellWidth: _paintState.metrics.cellWidth, + cellHeight: _paintState.metrics.cellHeight, + paddingLeft: _surfacePadding.left, + paddingRight: _surfacePadding.right, + paddingTop: _surfacePadding.top, + paddingBottom: _surfacePadding.bottom, + devicePixelRatio: dpr, + ), + ); + } + _cellWidthPx = cellWidthPx; + _cellHeightPx = cellHeightPx; + _lastCellWidth = _paintState.metrics.cellWidth; + _lastCellHeight = _paintState.metrics.cellHeight; + _lastDevicePixelRatio = dpr; + } + _lastSurfacePadding = _surfacePadding; - if (gridChanged || atlasReconfigured) _markFrameDirty(); + _syncScrollLayout(); - _performingLayout = false; - } + // Grid changes invalidate every row's sprite slot layout. Atlas + // rebinding invalidates atlas references inside the pipeline. + if (gridChanged) _pipeline.markAllRowsDirty(); - void _applyTerminalThemeColors() { - _terminal.foreground = _paintState.theme.foreground.toRgbColor(); - _terminal.background = _paintState.theme.background.toRgbColor(); - // Sentinel cursor colors (cellForeground/cellBackground) can't be - // reported as a single RGB, so we only push a fixed color down to - // libghostty; the flterm cursor painter resolves sentinels locally. - _terminal.cursorColor = _paintState.theme.cursor.color?.fixedColor - ?.toRgbColor(); - _terminal.palette = [ - for (var i = 0; i < 256; i++) _paintState.theme.palette[i].toRgbColor(), - ]; + if (geometryChanged || atlasReconfigured) _markFrameDirty(); + } finally { + _performingLayout = false; + } } bool _acquireAtlasForCurrentConfig({double? dpr, bool force = false}) { final config = AtlasConfig.fromTheme( theme: _paintState.theme, metrics: _paintState.metrics, - devicePixelRatio: dpr ?? _currentDevicePixelRatio, + devicePixelRatio: dpr ?? _devicePixelRatio, ); if (!force && config == _atlasHandle.config) return false; @@ -517,15 +569,6 @@ class TerminalRenderBox extends RenderBox { return true; } - double get _currentDevicePixelRatio { - return WidgetsBinding - .instance - .platformDispatcher - .views - .first - .devicePixelRatio; - } - static bool _listEquals(List a, List b) { if (identical(a, b)) return true; if (a.length != b.length) return false; @@ -540,12 +583,6 @@ class TerminalRenderBox extends RenderBox { markNeedsPaint(); } - void _onRenderObserverChanged() { - _paintState.cursorFocused = _renderObserver.hasFocus; - _pipeline.refreshCursorGlyph(); - markNeedsPaint(); - } - void _onScroll() { if (_performingLayout) return; if (_paintState.rows == 0 || _paintState.metrics.cellHeight <= 0) return; @@ -563,8 +600,12 @@ class TerminalRenderBox extends RenderBox { final targetRow = (pixels / cellHeight).floor(); if (targetRow == scrollbar.offset) return; - _terminal.scrollToRow(targetRow); - _onViewportChanged?.call(); + _applyingViewportIntent = true; + try { + _onViewportRowChanged(targetRow); + } finally { + _applyingViewportIntent = false; + } _markFrameDirty(); } @@ -573,10 +614,24 @@ class TerminalRenderBox extends RenderBox { // When scrollback length changes, a layout pass is needed because scroll // extents must be recalculated. For normal output (same scrollback // length), only a repaint is needed. - void _onTerminalChanged() { + void _onFrameChanged() { if (_paintState.rows == 0 || _performingLayout) return; + if (_applyingViewportIntent) { + _markFrameDirty(); + return; + } - if (_terminal.scrollbackRows != _lastScrollbackRows) { + final scrollbar = _terminal.scrollbar; + final scrollbackLen = scrollbar.total - scrollbar.visible; + final flutterRow = (_offset.pixels / _paintState.metrics.cellHeight) + .floor(); + if (flutterRow != scrollbar.offset) { + _pendingViewportRow = scrollbar.offset; + _stickToBottom = scrollbackLen <= 0 || scrollbar.offset >= scrollbackLen; + } + + if (_terminal.scrollbackRows != _lastScrollbackRows || + _pendingViewportRow != null) { _needsFrameSync = true; markNeedsLayout(); return; @@ -596,17 +651,34 @@ class TerminalRenderBox extends RenderBox { _offset.applyViewportDimension(size.height); if (_terminal.activeScreen == .alternate) { + _primaryStickToBottom ??= _stickToBottom; + _pendingViewportRow = null; _offset.applyContentDimensions(0, 0); _lastScrollbackRows = 0; _stickToBottom = true; return; } + final primaryStickToBottom = _primaryStickToBottom; + if (primaryStickToBottom != null) { + _stickToBottom = primaryStickToBottom; + _primaryStickToBottom = null; + } + final scrollbar = _terminal.scrollbar; final scrollbackLen = scrollbar.total - scrollbar.visible; final cellHeight = _paintState.metrics.cellHeight; final maxExtent = scrollbackLen * cellHeight; + final pendingViewportRow = _pendingViewportRow; + _pendingViewportRow = null; + if (pendingViewportRow != null) { + final targetPixels = + pendingViewportRow.clamp(0, scrollbackLen) * cellHeight; + final correction = targetPixels - _offset.pixels; + if (correction.abs() > 0.01) _offset.correctBy(correction); + } + // Detect if the terminal was scrolled to bottom externally. if (!_stickToBottom && scrollbackLen > 0 && @@ -618,8 +690,7 @@ class TerminalRenderBox extends RenderBox { final correction = maxExtent - _offset.pixels; if (correction.abs() > 0.01) _offset.correctBy(correction); if (scrollbar.offset < scrollbackLen) { - _terminal.scrollToBottom(); - _onViewportChanged?.call(); + _onViewportRowChanged(scrollbackLen); } } _offset.applyContentDimensions(0, maxExtent); @@ -641,11 +712,3 @@ class TerminalRenderBox extends RenderBox { ); } } - -extension on Color { - RgbColor toRgbColor() => RgbColor( - (r * 255.0).round().clamp(0, 255), - (g * 255.0).round().clamp(0, 255), - (b * 255.0).round().clamp(0, 255), - ); -} diff --git a/packages/flterm/lib/src/widgets/compression_scheduler.dart b/packages/flterm/lib/src/view/compression_scheduler.dart similarity index 80% rename from packages/flterm/lib/src/widgets/compression_scheduler.dart rename to packages/flterm/lib/src/view/compression_scheduler.dart index 7480fc84..f5b33939 100644 --- a/packages/flterm/lib/src/widgets/compression_scheduler.dart +++ b/packages/flterm/lib/src/view/compression_scheduler.dart @@ -6,21 +6,27 @@ import 'package:flutter/scheduler.dart' show SchedulerBinding; import 'package:libghostty/libghostty.dart' show TerminalCompressionResult; import 'package:meta/meta.dart' show internal; -// Compression becomes pending only when: -// - the terminal compression activity token changes; -// - schedule() explicitly requests it. -// -// Pending compression waits for 250 ms without reported terminal activity, then -// queues one bounded step at Flutter's idle priority. Activity with an -// unchanged token postpones pending compression but never schedules it. -// Incremental steps yield for 1 ms before re-entering Flutter's idle queue. The -// quiet period restarts for every report while compression is pending because -// parsing, compression, and frame scheduling share the UI isolate. Queued steps -// recheck the activity token and generation so newer terminal work, completion, -// unsupported targets, cancellation, and disposal all stop stale work. +/// Schedules bounded scrollback compression after terminal activity becomes +/// quiet. +/// +/// Compression becomes pending when the activity token changes or [schedule] +/// explicitly requests it. Pending work waits 250 milliseconds without new +/// activity, then runs one bounded step at Flutter's idle priority. Incremental +/// results yield for 1 millisecond before scheduling another idle step so +/// parsing and frames retain priority. The quiet period restarts after every +/// activity report while compression remains pending. +/// +/// Flutter idle tasks cannot be canceled. A generation token and a fresh +/// activity read therefore guard every queued step against newer terminal +/// work, cancellation, unsupported targets, and disposal. @internal final class CompressionScheduler { + /// Gives terminal parsing and frame production a quiet window before + /// compression starts. static const _idleDelay = Duration(milliseconds: 250); + + /// Lets the UI isolate handle pending input and frames between compression + /// steps. static const _continuationDelay = Duration(milliseconds: 1); final ValueGetter _readActivity; @@ -30,9 +36,9 @@ final class CompressionScheduler { Timer? _timer; int _activity; int _generation; - bool _compressionPending; - bool _unsupported; bool _disposed; + bool _unsupported; + bool _compressionPending; /// Uses `readActivity` to invalidate stale work and invokes `compress` only /// from scheduled idle tasks. diff --git a/packages/flterm/lib/src/view/terminal_cursor_blink.dart b/packages/flterm/lib/src/view/terminal_cursor_blink.dart new file mode 100644 index 00000000..a1a0ad47 --- /dev/null +++ b/packages/flterm/lib/src/view/terminal_cursor_blink.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Owns cursor blink timing and visibility for one terminal view. +/// +/// [sync] restarts the phase interval when focus, terminal mode, viewport +/// position, or theme timing changes. Disabling blinking always restores the +/// visible phase so a paused cursor cannot remain hidden. +@internal +final class TerminalCursorBlink extends ValueNotifier { + Timer? _timer; + + TerminalCursorBlink() : super(true); + + void sync({required bool enabled, required Duration interval}) { + _timer?.cancel(); + _timer = enabled ? Timer.periodic(interval, (_) => value = !value) : null; + if (!value) value = true; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/packages/flterm/lib/src/widgets/terminal_scope.dart b/packages/flterm/lib/src/view/terminal_scope.dart similarity index 86% rename from packages/flterm/lib/src/widgets/terminal_scope.dart rename to packages/flterm/lib/src/view/terminal_scope.dart index ac6f6ef8..e6789cd7 100644 --- a/packages/flterm/lib/src/widgets/terminal_scope.dart +++ b/packages/flterm/lib/src/view/terminal_scope.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../rendering/terminal_render_cache.dart'; +/// Returns the shared terminal render cache nearest to [context], if any. TerminalRenderCache? terminalScopeRenderCacheOf(BuildContext context) { return context .dependOnInheritedWidgetOfExactType<_TerminalScopeInherited>() @@ -22,7 +23,7 @@ class TerminalScope extends StatefulWidget { State createState() => _TerminalScopeState(); } -class _TerminalScopeState extends State { +final class _TerminalScopeState extends State { final _renderCache = TerminalRenderCache(); @override @@ -40,7 +41,7 @@ class _TerminalScopeState extends State { } } -class _TerminalScopeInherited extends InheritedWidget { +final class _TerminalScopeInherited extends InheritedWidget { final TerminalRenderCache renderCache; const _TerminalScopeInherited({ diff --git a/packages/flterm/lib/src/widgets/terminal_scroll_controller.dart b/packages/flterm/lib/src/view/terminal_scroll_controller.dart similarity index 76% rename from packages/flterm/lib/src/widgets/terminal_scroll_controller.dart rename to packages/flterm/lib/src/view/terminal_scroll_controller.dart index 78e2c386..fda64629 100644 --- a/packages/flterm/lib/src/widgets/terminal_scroll_controller.dart +++ b/packages/flterm/lib/src/view/terminal_scroll_controller.dart @@ -5,8 +5,8 @@ import 'package:meta/meta.dart'; /// Scroll controller for [TerminalView]. /// /// On the primary screen, scrolls through the scrollback buffer like -/// a normal [ScrollController]. On the alternate screen (vim, less), -/// scroll gestures are converted to cursor key input instead. +/// a normal [ScrollController]. On the alternate screen, [TerminalView] +/// forwards scroll gestures as mouse reports or alternate-scroll key input. /// /// Created internally by [TerminalView] when not provided. Supply your /// own to observe or control the scroll position programmatically. @@ -23,7 +23,7 @@ import 'package:meta/meta.dart'; /// scrollController.jumpTo(0); /// ``` class TerminalScrollController extends ScrollController { - var _activeScreen = TerminalScreen.primary; + TerminalScreen _activeScreen = .primary; TerminalScrollController(); @@ -40,7 +40,7 @@ class TerminalScrollController extends ScrollController { } @override - TerminalScrollPosition createScrollPosition( + ScrollPosition createScrollPosition( ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition, @@ -54,12 +54,12 @@ class TerminalScrollController extends ScrollController { } } -/// Scroll position used by [TerminalScrollController]. +/// Preserves primary-screen scrollback while adapting alternate-screen layout. /// -/// Adapts to the active terminal screen. On the alternate screen, -/// accepts all scroll extents so gestures are never rejected. Saves -/// and restores the primary screen scroll offset across screen switches. -class TerminalScrollPosition extends ScrollPositionWithSingleContext { +/// Alternate screens expose unbounded extents because touch and wheel input is +/// routed to terminal applications rather than moving the Flutter viewport. +@internal +final class TerminalScrollPosition extends ScrollPositionWithSingleContext { double? _savedPixels; TerminalScreen _activeScreen; @@ -75,8 +75,10 @@ class TerminalScrollPosition extends ScrollPositionWithSingleContext { @internal set activeScreen(TerminalScreen value) { if (_activeScreen == value) return; - if (value == .alternate && hasPixels) { - _savedPixels = pixels; + if (value == .alternate) { + goIdle(); + if (hasPixels) _savedPixels = pixels; + if (hasPixels) correctPixels(0); } _activeScreen = value; if (value == .primary && _savedPixels != null) { diff --git a/packages/flterm/lib/src/widgets/terminal_shortcut_scope.dart b/packages/flterm/lib/src/view/terminal_shortcut_scope.dart similarity index 92% rename from packages/flterm/lib/src/widgets/terminal_shortcut_scope.dart rename to packages/flterm/lib/src/view/terminal_shortcut_scope.dart index 97cdaed9..16ed9238 100644 --- a/packages/flterm/lib/src/widgets/terminal_shortcut_scope.dart +++ b/packages/flterm/lib/src/view/terminal_shortcut_scope.dart @@ -4,29 +4,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter/widgets.dart'; -import 'terminal_controller.dart'; +import '../controller/terminal_controller.dart'; /// Clear terminal screen and scrollback. @internal -class ClearIntent extends Intent { +final class ClearIntent extends Intent { const ClearIntent(); } /// Copy the current terminal selection. @internal -class CopyIntent extends Intent { +final class CopyIntent extends Intent { const CopyIntent(); } /// Paste clipboard content into the terminal. @internal -class PasteIntent extends Intent { +final class PasteIntent extends Intent { const PasteIntent(); } /// Select all terminal content. @internal -class SelectAllIntent extends Intent { +final class SelectAllIntent extends Intent { const SelectAllIntent(); } @@ -78,7 +78,7 @@ abstract final class TerminalShortcuts { /// ) /// ``` @internal -class TerminalShortcutScope extends StatelessWidget { +final class TerminalShortcutScope extends StatelessWidget { final Widget child; final VoidCallback? onPaste; final TerminalController controller; @@ -130,7 +130,7 @@ class TerminalShortcutScope extends StatelessWidget { } } -class _ConditionalAction extends Action { +final class _ConditionalAction extends Action { final VoidCallback? onInvokeFn; final ValueGetter isEnabledFn; diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/view/terminal_view.dart similarity index 53% rename from packages/flterm/lib/src/widgets/terminal_view.dart rename to packages/flterm/lib/src/view/terminal_view.dart index abeb8c82..bb15134c 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/view/terminal_view.dart @@ -2,20 +2,19 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:libghostty/libghostty.dart' - show RgbColor, colorPerceivedLuminance; +import '../controller/terminal_controller.dart'; import '../foundation.dart'; +import '../input/terminal_gesture_detector.dart'; +import '../links/link_interaction.dart'; import '../links/link_settings.dart'; import '../rendering.dart'; import '../rendering/terminal_render_cache.dart'; -import 'link_interaction.dart'; -import 'terminal_controller.dart'; -import 'terminal_gesture_detector.dart'; +import 'terminal_cursor_blink.dart'; import 'terminal_scope.dart'; import 'terminal_scroll_controller.dart'; import 'terminal_shortcut_scope.dart'; -import 'terminal_view_binding.dart'; +import 'terminal_view_attachment.dart'; /// Displays a terminal and handles user interaction. /// @@ -24,7 +23,9 @@ import 'terminal_view_binding.dart'; /// scrolling, gestures, focus, and keyboard shortcuts. /// /// Fills the available space and computes the grid dimensions (columns -/// and rows) from the font metrics and pixel area. +/// and rows) from the font metrics and pixel area. A controller can be attached +/// to only one view at a time. The view releases that attachment when removed +/// or when [controller] changes, but it never disposes the controller. /// /// ```dart /// final controller = TerminalController() @@ -39,8 +40,9 @@ import 'terminal_view_binding.dart'; class TerminalView extends StatefulWidget { /// The controller that owns the terminal instance. /// - /// Can be swapped at runtime; the view detaches from the old controller - /// and attaches to the new one. + /// Can be swapped at runtime; the view releases the old controller and binds + /// its local focus, input, scroll, and rendering adapters to the new one. The + /// new controller must not already be attached to another [TerminalView]. final TerminalController controller; /// Visual style. Defaults to [TerminalTheme.dark()]. @@ -60,8 +62,9 @@ class TerminalView extends StatefulWidget { /// Whether to show the soft keyboard when focus is gained. /// - /// The keyboard can still be shown programmatically via - /// [TerminalController.showKeyboard] regardless of this setting. + /// Focus and keyboard state are owned by this view. When false, taps and + /// programmatic focus can still focus the terminal, but a focus gain does not + /// request a platform text-input connection. final bool showKeyboard; /// When to auto-hide the mouse cursor. @@ -76,13 +79,15 @@ class TerminalView extends StatefulWidget { /// Padding around the terminal grid. /// /// Filled with the theme background color. The grid is sized from - /// the remaining space after padding. Defaults to 8px on all sides. + /// the remaining space after padding. Mouse coordinates are translated + /// through this inset when reported to terminal applications. Defaults to + /// 8px on all sides. final EdgeInsets padding; - /// Scroll physics for scrollback navigation. + /// Scroll physics for scrollback and terminal scroll gestures. /// - /// Disabled automatically when the terminal program requests mouse - /// tracking, so gestures are forwarded as mouse events instead. + /// The platform default is used when null. The same physics controls touch + /// and trackpad momentum when gestures are forwarded to a terminal program. final ScrollPhysics? scrollPhysics; /// Scroll controller for programmatic scrollback access. @@ -92,8 +97,10 @@ class TerminalView extends StatefulWidget { /// Shortcut bindings merged over platform defaults. /// - /// Defaults: Cmd+C/V/A/K on macOS, Ctrl+Shift+C/V/A/K on Linux, - /// Ctrl+C/V/A/K on Windows. + /// Defaults: Command+C/V/A/K on macOS and iOS, + /// Control+Shift+C/V/A/K on Linux and Fuchsia, and Control+C/V/A/K on + /// Windows and Android. Custom entries with the same activator replace the + /// corresponding default. final Map? shortcuts; /// Raw TTF/OTF font file bytes for exact metric extraction. @@ -129,50 +136,35 @@ class TerminalView extends StatefulWidget { State createState() => _TerminalViewState(); } -class _TerminalViewState extends State { - late FocusNode _focusNode; - late TerminalTheme _theme; - late CellMetrics _metrics; - late TerminalViewBinding _binding; - late TerminalScrollController _scrollController; - final _links = LinkInteraction(); +final class _TerminalViewState extends State { final _rendererKey = GlobalKey(); + final _links = LinkInteraction(); + final _cursorBlink = TerminalCursorBlink(); + final _mouseCursorHidden = ValueNotifier(false); + late final _mouseInteraction = Listenable.merge([_links, _mouseCursorHidden]); - Uint8List? _resolvedFontData; + late TerminalViewAttachment _attachment; + var _devicePixelRatio = 1.0; + late FocusNode _focusNode; + late ScrollPhysics _gestureScrollPhysics; + late CellMetrics _metrics; var _ownsFocusNode = false; var _ownsScrollController = false; - var _mouseCursorHidden = false; - var _lastAlternatePixels = 0.0; - var _visibleCols = 0; - var _visibleRows = 0; - var _devicePixelRatio = 1.0; - Timer? _blinkTimer; - var _blinkVisible = true; + Uint8List? _resolvedFontData; + late TerminalScrollController _scrollController; + late TerminalTheme _theme; int? _viewId; TerminalController get _controller => widget.controller; - Brightness get _themeBrightness { - final background = _theme.background; - final luminance = colorPerceivedLuminance( - RgbColor( - (background.r * 255.0).round(), - (background.g * 255.0).round(), - (background.b * 255.0).round(), - ), - ); - return luminance > 0.5 ? .light : .dark; - } - @override Widget build(BuildContext context) { final cache = terminalScopeRenderCacheOf(context); - if (cache != null) return _build(context, cache); + if (cache != null) return _build(cache); return TerminalScope( child: Builder( - builder: (context) => - _build(context, terminalScopeRenderCacheOf(context)!), + builder: (context) => _build(terminalScopeRenderCacheOf(context)!), ), ); } @@ -180,13 +172,15 @@ class _TerminalViewState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _updateGestureScrollPhysics(); + final viewId = View.of(context).viewId; if (_viewId != viewId) { _viewId = viewId; - _binding.attach(_focusNode, _scrollController, viewId: viewId); + _attachment.attach(_focusNode, _scrollController, viewId: viewId); } - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final devicePixelRatio = View.of(context).devicePixelRatio; if (_devicePixelRatio == devicePixelRatio) return; _devicePixelRatio = devicePixelRatio; @@ -199,56 +193,77 @@ class _TerminalViewState extends State { void didUpdateWidget(TerminalView oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_onControllerChanged); - _binding.detach(); - _binding = _asBinding(_controller); - _binding.brightness = _themeBrightness; - _binding.attach(_focusNode, _scrollController, viewId: _viewId!); - _controller.addListener(_onControllerChanged); - _links.invalidateContent(); + final controllerChanged = widget.controller != oldWidget.controller; + final focusNodeChanged = widget.focusNode != oldWidget.focusNode; + final scrollControllerChanged = + widget.scrollController != oldWidget.scrollController; + + if (controllerChanged) { + _attachment.removeListener(_onControllerChanged); + _attachment.dispose(); + } else if (focusNodeChanged) { + _attachment.detach(); } - if (widget.focusNode != oldWidget.focusNode) { + if (focusNodeChanged) { if (_ownsFocusNode) _focusNode.dispose(); _focusNode = widget.focusNode ?? FocusNode(); _ownsFocusNode = widget.focusNode == null; - _binding.attach(_focusNode, _scrollController, viewId: _viewId!); } - if (widget.scrollController != oldWidget.scrollController) { + if (scrollControllerChanged) { _scrollController.removeListener(_onScrollChanged); if (_ownsScrollController) _scrollController.dispose(); _scrollController = widget.scrollController ?? TerminalScrollController(); _ownsScrollController = widget.scrollController == null; - _scrollController.activeScreen = _controller.activeScreen; _scrollController.addListener(_onScrollChanged); - _binding.attach(_focusNode, _scrollController, viewId: _viewId!); } - if (widget.theme != oldWidget.theme) { - final oldTheme = _theme; - _theme = widget.theme ?? TerminalTheme.dark(); + if (widget.scrollPhysics != oldWidget.scrollPhysics) { + _updateGestureScrollPhysics(); + } - // Only recalculate metrics when font properties change. - // Color-only theme changes must not trigger metric recalculation, - // which would clear the atlas and cause decoration flicker. - if (_theme.fontSize != oldTheme.fontSize || - _theme.fontWeight != oldTheme.fontWeight || - _theme.fontFamily != oldTheme.fontFamily || - _theme.fontFamilyFallback != oldTheme.fontFamilyFallback) { - _metrics = _measureMetrics(); - _links.cancel(); - } + if (controllerChanged) { + _attachment = TerminalViewAttachment(_controller); + _attachment.addListener(_onControllerChanged); + _links.invalidateContent(); + } + + if (controllerChanged || focusNodeChanged || scrollControllerChanged) { + _scrollController.activeScreen = _controller.activeScreen; + _attachment.attach(_focusNode, _scrollController, viewId: _viewId!); + } - _binding.brightness = _themeBrightness; + final oldTheme = _theme; + final themeChanged = widget.theme != oldWidget.theme; + if (themeChanged) { + _theme = widget.theme ?? TerminalTheme.dark(); + } + if (controllerChanged || themeChanged) _attachment.applyTheme(_theme); + + final fontDataChanged = widget.fontData != oldWidget.fontData; + final fontFamilyChanged = _theme.fontFamily != oldTheme.fontFamily; + if (fontFamilyChanged) _resolvedFontData = null; + + final fontMetricsChanged = + fontDataChanged || + _theme.fontSize != oldTheme.fontSize || + _theme.fontWeight != oldTheme.fontWeight || + fontFamilyChanged || + _theme.fontFamilyFallback != oldTheme.fontFamilyFallback; + if (fontMetricsChanged) { + _metrics = _measureMetrics(); + _links.cancel(); + } + + if (themeChanged) { if (_theme.cursor.blinkInterval != oldTheme.cursor.blinkInterval) { _syncBlink(); } - if (_theme.fontFamily != oldTheme.fontFamily && widget.fontData == null) { - _resolvedFontData = null; - unawaited(_resolveFontData(_theme.fontFamily)); - } + } + if (widget.fontData == null && + (fontFamilyChanged || (fontDataChanged && _resolvedFontData == null))) { + unawaited(_resolveFontData(_theme.fontFamily)); } _syncLinkInteraction(); @@ -256,9 +271,11 @@ class _TerminalViewState extends State { @override void dispose() { - _blinkTimer?.cancel(); - _controller.removeListener(_onControllerChanged); - _binding.detach(); + _cursorBlink.dispose(); + _mouseCursorHidden.dispose(); + _links.dispose(); + _attachment.removeListener(_onControllerChanged); + _attachment.dispose(); if (_ownsFocusNode) _focusNode.dispose(); _scrollController.removeListener(_onScrollChanged); if (_ownsScrollController) _scrollController.dispose(); @@ -269,14 +286,13 @@ class _TerminalViewState extends State { void initState() { super.initState(); - _binding = _asBinding(_controller); + _attachment = TerminalViewAttachment(_controller); _focusNode = widget.focusNode ?? FocusNode(); _ownsFocusNode = widget.focusNode == null; _theme = widget.theme ?? TerminalTheme.dark(); - _devicePixelRatio = - WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio; + _attachment.applyTheme(_theme); _metrics = _measureMetrics(); if (widget.fontData == null) { @@ -285,23 +301,18 @@ class _TerminalViewState extends State { _scrollController = widget.scrollController ?? TerminalScrollController(); _ownsScrollController = widget.scrollController == null; + _scrollController.activeScreen = _controller.activeScreen; _scrollController.addListener(_onScrollChanged); - _binding.brightness = _themeBrightness; - _controller.addListener(_onControllerChanged); + _attachment.addListener(_onControllerChanged); _syncLinkInteraction(); } - Widget _build(BuildContext context, TerminalRenderCache cache) { + Widget _build(TerminalRenderCache cache) { return GestureDetector( behavior: .translucent, - onTap: _controller.requestFocus, + onTap: _attachment.requestFocus, child: ColoredBox( - // Backdrop tinted by backgroundOpacity. The repaint boundary - // TerminalRenderBox skips its own grid fill below 1.0 and - // relies on this as the sole tint source, so default background - // cells show through to whatever sits behind the widget without - // composing twice across the two layers. color: _theme.background.withValues(alpha: _theme.backgroundOpacity), child: Padding( padding: widget.padding, @@ -312,42 +323,10 @@ class _TerminalViewState extends State { controller: _controller, shortcuts: widget.shortcuts, enableSelectAll: widget.gestureSettings.selectAllShortcut, - child: MouseRegion( - onHover: _handleMouseHover, - onExit: _handleMouseExit, - cursor: _effectiveMouseCursor(), - child: Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onFocusChange: _handleFocusChange, - child: TerminalGestureDetector( - links: _links, - metrics: _metrics, - binding: _binding, - visibleRows: _visibleRows, - settings: widget.gestureSettings, - scrollController: _scrollController, - onLinkActivate: widget.linkSettings.onActivate, - child: Scrollable( - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => TerminalRenderer( - key: _rendererKey, - theme: _theme, - offset: offset, - metrics: _metrics, - renderObserver: _controller, - terminal: _binding.terminal, - renderCache: cache, - preeditText: _binding.preeditText, - blinkVisible: _blinkVisible, - linkSnapshot: _links.snapshot(), - onResize: _handleResize, - onViewportChanged: _binding.handleViewportChanged, - ), - ), - ), - ), + child: ListenableBuilder( + listenable: _attachment.interaction, + builder: (_, _) => + _buildInteraction(cache, _gestureScrollPhysics), ), ), ), @@ -356,31 +335,93 @@ class _TerminalViewState extends State { ); } + Widget _buildInteraction( + TerminalRenderCache renderCache, + ScrollPhysics gestureScrollPhysics, + ) { + final interaction = _attachment.interaction.value; + final scrollPhysics = interaction.activeScreen == .alternate + ? const NeverScrollableScrollPhysics() + : widget.scrollPhysics; + final content = Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + child: Scrollable( + controller: _scrollController, + physics: scrollPhysics, + viewportBuilder: (_, offset) => TerminalGestureDetector( + links: _links, + metrics: _metrics, + attachment: _attachment, + interaction: interaction, + settings: widget.gestureSettings, + scrollPhysics: gestureScrollPhysics, + scrollController: _scrollController, + onLinkActivate: widget.linkSettings.onActivate, + child: ListenableBuilder( + listenable: Listenable.merge([ + _focusNode, + _attachment.input, + _cursorBlink, + _links, + ]), + builder: (context, _) => TerminalRenderer( + key: _rendererKey, + theme: _theme, + offset: offset, + metrics: _metrics, + focused: _focusNode.hasFocus, + frameSource: _attachment.frameSource, + renderCache: renderCache, + surfacePadding: widget.padding, + devicePixelRatio: _devicePixelRatio, + preeditText: _attachment.input.preeditText, + blinkVisible: _cursorBlink.value, + linkSnapshot: _links.snapshot(), + onGeometryChanged: _handleResize, + onViewportRowChanged: _attachment.handleViewportRowChanged, + ), + ), + ), + ), + ); + return ListenableBuilder( + listenable: _mouseInteraction, + child: content, + builder: (context, child) => MouseRegion( + onHover: _handleMouseHover, + onExit: _handleMouseExit, + cursor: _effectiveMouseCursor(), + child: child, + ), + ); + } + MouseCursor _effectiveMouseCursor() { - if (_mouseCursorHidden) return SystemMouseCursors.none; + if (_mouseCursorHidden.value) return SystemMouseCursors.none; if (_links.highlighted != null) return SystemMouseCursors.click; if (_controller.mouseTracking != .none) return SystemMouseCursors.basic; return SystemMouseCursors.text; } void _handleFocusChange(bool focused) { - if (focused && - widget.showKeyboard && - _controller.keyboardState != .disabled) { - _controller.showKeyboard(); + _syncBlink(focused: focused); + if (focused && widget.showKeyboard) { + _attachment.input.showKeyboard(); _updateTextInputGeometry(); } } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { _updateTextInputGeometry(); - final result = _binding.handleKeyEvent(event); + final result = _attachment.input.handleKeyEvent(event); if (result == .handled || result == .skipRemainingHandlers) { _updateTextInputGeometry(); _syncBlink(); - if (widget.mouseAutoHide == .onInput && !_mouseCursorHidden) { - setState(() => _mouseCursorHidden = true); + if (widget.mouseAutoHide == .onInput && !_mouseCursorHidden.value) { + _mouseCursorHidden.value = true; } } @@ -388,29 +429,15 @@ class _TerminalViewState extends State { return result; } - void _handleMouseExit(PointerExitEvent event) { - final previous = _links.highlighted; - _links.cancelHover(); - if (previous != null) setState(() {}); - } + void _handleMouseExit(PointerExitEvent event) => _links.cancelHover(); void _handleMouseHover(PointerHoverEvent event) { - final previous = _links.highlighted; _links.handleHover( localPosition: event.localPosition, metrics: _metrics, - virtualMods: _binding.virtualMods, + virtualMods: _attachment.virtualMods, ); - if (_mouseCursorHidden || _links.highlighted != previous) { - setState(() => _mouseCursorHidden = false); - } - } - - void _syncHoveredLink() { - final previous = _links.highlighted; - _links.refreshHover(metrics: _metrics, virtualMods: _binding.virtualMods); - if (_links.highlighted == previous) return; - setState(() {}); + _mouseCursorHidden.value = false; } Future _handlePaste() async { @@ -419,16 +446,8 @@ class _TerminalViewState extends State { _controller.paste(data.text!); } - void _handleResize(int cols, int rows) { - _visibleCols = cols; - _visibleRows = rows; - _binding.handleResize( - cols: cols, - rows: rows, - metrics: _metrics, - padding: .zero, - devicePixelRatio: _devicePixelRatio, - ); + void _handleResize(TerminalResizeEvent event) { + _attachment.handleResize(event); _syncLinkInteraction(); } @@ -446,17 +465,70 @@ class _TerminalViewState extends State { void _onControllerChanged() { _links.invalidateContent(); _syncLinkInteraction(); - _scrollController.activeScreen = _controller.activeScreen; + final activeScreen = _controller.activeScreen; + if (_scrollController.activeScreen != activeScreen) { + _scrollController.activeScreen = activeScreen; + } + _updateTextInputGeometry(); + _syncBlink(); + } + + void _onScrollChanged() { + _syncBlink(); + _links.invalidateContent(); _updateTextInputGeometry(); - setState(_syncBlink); + } + + /// Asynchronously resolves font data and recomputes metrics when found. + Future _resolveFontData(String fontFamily) async { + if (!mounted || + widget.fontData != null || + _theme.fontFamily != fontFamily) { + return; + } + final data = await FontDataResolver.resolve(fontFamily); + if (data == null || + !mounted || + widget.fontData != null || + _theme.fontFamily != fontFamily) { + return; + } + + _resolvedFontData = data; + _metrics = _measureMetrics(fontData: data); + _links.cancel(); + setState(() {}); + } + + void _updateGestureScrollPhysics() { + final behavior = ScrollConfiguration.of(context); + final defaultPhysics = behavior.getScrollPhysics(context); + _gestureScrollPhysics = + widget.scrollPhysics?.applyTo(defaultPhysics) ?? defaultPhysics; + } + + void _syncBlink({bool? focused}) { + _cursorBlink.sync( + enabled: + (focused ?? _focusNode.hasFocus) && _attachment.cursorBlinkEnabled, + interval: _theme.cursor.blinkInterval, + ); + } + + void _syncHoveredLink() { + _links.refreshHover( + metrics: _metrics, + virtualMods: _attachment.virtualMods, + ); } void _syncLinkInteraction() { final cwd = _controller.pwd; + final geometry = _attachment.terminal.geometry; final context = LinkContext( - terminal: _binding.terminal, - rows: _visibleRows, - cols: _visibleCols, + terminal: _attachment.terminal, + rows: geometry.rows, + cols: geometry.cols, cwd: cwd.isEmpty ? null : cwd, ); @@ -467,22 +539,6 @@ class _TerminalViewState extends State { ); } - void _onScrollChanged() { - _syncBlink(); - if (!_scrollController.hasClients) return; - final cellHeight = _metrics.cellHeight; - if (cellHeight <= 0) return; - final pixels = _scrollController.position.pixels; - final delta = pixels - _lastAlternatePixels; - final lines = (delta / cellHeight).truncate(); - if (lines == 0) return; - _lastAlternatePixels += lines * cellHeight; - _binding.handleScroll(lines); - _links.invalidateContent(); - _syncLinkInteraction(); - _updateTextInputGeometry(); - } - void _updateTextInputGeometry() { final renderObject = _rendererKey.currentContext?.findRenderObject(); if (renderObject is! TerminalRenderBox || @@ -491,43 +547,11 @@ class _TerminalViewState extends State { return; } - _binding.updateTextInputGeometry( + _attachment.input.updateTextInputGeometry( editableSize: renderObject.size, transform: renderObject.getTransformTo(null), caretRect: renderObject.textInputCaretRect, composingRect: renderObject.textInputComposingRect, ); } - - /// Asynchronously resolves font data and recomputes metrics when found. - Future _resolveFontData(String fontFamily) async { - if (!mounted || _theme.fontFamily != fontFamily) return; - final data = await FontDataResolver.resolve(fontFamily); - if (data == null) return; - - _resolvedFontData = data; - _metrics = _measureMetrics(fontData: data); - _links.cancel(); - setState(() {}); - } - - void _syncBlink() { - _blinkTimer?.cancel(); - _blinkTimer = null; - if (_binding.cursorBlinks) { - _blinkTimer = Timer.periodic(_theme.cursor.blinkInterval, (_) { - if (mounted) setState(() => _blinkVisible = !_blinkVisible); - }); - } - if (!_blinkVisible) setState(() => _blinkVisible = true); - } - - static TerminalViewBinding _asBinding(TerminalController controller) { - assert( - controller is TerminalViewBinding, - 'TerminalController must implement TerminalViewBinding. ' - 'Use the TerminalController() factory constructor.', - ); - return controller as TerminalViewBinding; - } } diff --git a/packages/flterm/lib/src/view/terminal_view_attachment.dart b/packages/flterm/lib/src/view/terminal_view_attachment.dart new file mode 100644 index 00000000..bf6b79ed --- /dev/null +++ b/packages/flterm/lib/src/view/terminal_view_attachment.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:libghostty/libghostty.dart' hide Listenable; + +import '../controller/terminal_controller.dart'; +import '../foundation.dart'; +import '../input/terminal_input_adapter.dart'; +import '../input/terminal_input_event.dart'; +import '../interaction/terminal_selection.dart'; +import '../rendering/terminal_frame_source.dart'; +import 'compression_scheduler.dart'; + +/// Terminal modes that must be observed atomically by gesture routing. +/// +/// One immutable value prevents a rebuild from combining an active screen, +/// mouse mode, and alternate-scroll flag sampled from different terminal +/// notifications. +@immutable +final class TerminalInteractionState { + /// The active primary or alternate terminal screen. + final TerminalScreen activeScreen; + + /// The terminal's active mouse-reporting mode. + final MouseTracking mouseTracking; + + /// Whether alternate-screen scrolling is enabled. + final bool alternateScroll; + + const TerminalInteractionState({ + required this.activeScreen, + required this.mouseTracking, + required this.alternateScroll, + }); + + @override + int get hashCode => Object.hash(activeScreen, mouseTracking, alternateScroll); + + @override + bool operator ==(Object other) { + return other is TerminalInteractionState && + other.activeScreen == activeScreen && + other.mouseTracking == mouseTracking && + other.alternateScroll == alternateScroll; + } +} + +/// Owns one Flutter view's attachment to a terminal controller. +/// +/// The attachment is the only bridge that subscribes view resources to a +/// controller. It owns focus and text input, frame invalidation, scroll-aware +/// compression, theme reporting, and normalized event routing. Disposing it +/// releases the controller's single-view lease and every listener it created. +@internal +final class TerminalViewAttachment extends ChangeNotifier { + final Object _viewToken; + final TerminalInputAdapter input; + final TerminalControllerImpl _controller; + late final TerminalFrameSource frameSource; + late final CompressionScheduler _compressionScheduler; + late final ValueNotifier _interaction; + ScrollController? _scrollController; + var _disposed = false; + + factory TerminalViewAttachment(TerminalController controller) => + TerminalViewAttachment._(controller as TerminalControllerImpl); + + TerminalViewAttachment._(this._controller) + : _viewToken = _controller.attachView(), + input = TerminalInputAdapter(_controller) { + frameSource = TerminalFrameSource( + terminal, + viewportChanges: _controller.viewportChanges, + ); + _interaction = ValueNotifier(_readInteractionState()); + _compressionScheduler = CompressionScheduler( + readActivity: () => terminal.compressionActivity, + compress: terminal.compress, + ); + _controller.addListener(_handleControllerChanged); + terminal.addListener(_handleTerminalChanged); + } + + Mods get currentMods => _physicalMods | _controller.virtualMods; + + bool get cursorBlinkEnabled { + if (!_controller.cursorBlinking) return false; + if (terminal.activeScreen == .alternate) return true; + + final scrollController = _scrollController; + if (scrollController == null || !scrollController.hasClients) { + return terminal.isViewportActive; + } + final position = scrollController.position; + if (!position.hasContentDimensions) return terminal.isViewportActive; + return position.pixels >= position.maxScrollExtent - 1.0; + } + + ValueListenable get interaction => _interaction; + + MouseTracking get mouseTracking => _controller.mouseTracking; + + Terminal get terminal => _controller.terminal; + + Mods get virtualMods => _controller.virtualMods; + + Mods get _physicalMods { + final keyboard = HardwareKeyboard.instance; + var mods = const Mods.none(); + if (keyboard.isShiftPressed) mods |= const Mods.shift(); + if (keyboard.isControlPressed) mods |= const Mods.ctrl(); + if (keyboard.isAltPressed) mods |= const Mods.alt(); + if (keyboard.isMetaPressed) mods |= const Mods.superKey(); + return mods; + } + + void applyTheme(TerminalTheme theme) { + final background = _rgb(theme.background); + final Brightness brightness = colorPerceivedLuminance(background) > 0.5 + ? .light + : .dark; + input.keyboardAppearance = brightness; + _controller.setColorScheme(brightness == .light ? .light : .dark); + terminal + ..foreground = _rgb(theme.foreground) + ..background = background + ..cursorColor = theme.cursor.color?.fixedColor == null + ? null + : _rgb(theme.cursor.color!.fixedColor!) + ..palette = [for (var i = 0; i < 256; i++) _rgb(theme.palette[i])]; + } + + void attach( + FocusNode focusNode, + ScrollController scrollController, { + required int viewId, + }) { + _scrollController = scrollController; + input.attach(focusNode, viewId: viewId); + if (scrollController.hasClients) _compressionScheduler.schedule(); + } + + void cancelSelectionGesture() => _controller.cancelSelectionGesture(); + + void detach() { + _compressionScheduler.cancel(); + _scrollController = null; + input.detach(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + if (!_controller.isDisposed) { + _controller.removeListener(_handleControllerChanged); + terminal.removeListener(_handleTerminalChanged); + } + _compressionScheduler.dispose(); + input.dispose(); + frameSource.dispose(); + _interaction.dispose(); + _controller.detachView(_viewToken); + super.dispose(); + } + + void handleMouseEvent(TerminalMouseEvent event) => + _controller.handleMouseEvent(event); + + void handleResize(TerminalResizeEvent event) => + _controller.handleResize(event); + + void handleSelectionPress(TerminalSelectionPressEvent event) => + _controller.handleSelectionPress(event); + + void handleSelectionRelease(Position cell) => + _controller.handleSelectionRelease(cell); + + void handleTerminalScroll(TerminalScrollEvent event) => + _controller.handleTerminalScroll(event); + + void handleViewportRowChanged(int row) { + _controller.scrollToRow(row); + _compressionScheduler.notifyActivity(); + } + + void invalidateSelection() => _controller.invalidateSelection(); + + void requestFocus() => input.requestFocus(); + + void updateSelectionAutoscroll(TerminalSelectionAutoscrollEvent event) { + _controller.updateSelectionAutoscroll(event); + _compressionScheduler.notifyActivity(); + } + + void updateSelectionDrag(TerminalSelectionDragEvent event) => + _controller.updateSelectionDrag(event); + + void _handleControllerChanged() { + final next = _readInteractionState(); + if (_interaction.value != next) _interaction.value = next; + notifyListeners(); + } + + void _handleTerminalChanged() => _compressionScheduler.notifyActivity(); + + TerminalInteractionState _readInteractionState() { + final activeScreen = _controller.activeScreen; + return TerminalInteractionState( + activeScreen: activeScreen, + mouseTracking: _controller.mouseTracking, + alternateScroll: terminal.modeGet(const .alternateScroll()), + ); + } + + static RgbColor _rgb(Color color) => RgbColor( + (color.r * 255).round().clamp(0, 255), + (color.g * 255).round().clamp(0, 255), + (color.b * 255).round().clamp(0, 255), + ); +} diff --git a/packages/flterm/lib/src/widgets.dart b/packages/flterm/lib/src/widgets.dart deleted file mode 100644 index fc39d3d1..00000000 --- a/packages/flterm/lib/src/widgets.dart +++ /dev/null @@ -1,10 +0,0 @@ -export 'widgets/terminal_controller.dart'; -export 'widgets/terminal_controller_impl.dart'; -export 'widgets/terminal_gesture_detector.dart'; -export 'widgets/terminal_input_client.dart'; -export 'widgets/terminal_raw_gesture_detector.dart'; -export 'widgets/terminal_scope.dart'; -export 'widgets/terminal_scroll_controller.dart'; -export 'widgets/terminal_shortcut_scope.dart'; -export 'widgets/terminal_view.dart'; -export 'widgets/terminal_view_binding.dart'; diff --git a/packages/flterm/lib/src/widgets/terminal_controller.dart b/packages/flterm/lib/src/widgets/terminal_controller.dart deleted file mode 100644 index 6a76ebd0..00000000 --- a/packages/flterm/lib/src/widgets/terminal_controller.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/foundation.dart' hide Key; -import 'package:libghostty/libghostty.dart'; - -import '../foundation.dart'; -import 'terminal_controller_impl.dart'; - -/// Manages a terminal instance and bridges it with [TerminalView]. -/// -/// Create a controller, wire up [onOutput] to your backend, pass the -/// controller to a [TerminalView], and feed backend data into [write]. -/// The controller handles input encoding, selection, focus, and all -/// terminal state. -/// -/// Dispose when no longer needed. -/// -/// ```dart -/// final controller = TerminalController() -/// ..onOutput = (bytes) => pty.write(bytes) -/// ..onBell = () => playSound() -/// ..onTitleChanged = () => updateTitle(controller.title); -/// -/// TerminalView(controller: controller); -/// -/// pty.onData = (bytes) => controller.write(bytes); -/// controller.sendText('ls -la\n'); -/// ``` -abstract class TerminalController extends ChangeNotifier - implements TerminalRenderObserver { - /// Called with bytes to send to the backend (PTY, SSH, socket). - /// - /// Set this before calling [write]. Fires during [write], [sendKey], - /// [sendText], and [paste]. - ValueChanged? onOutput; - - /// Called when the terminal receives a BEL character (0x07). - VoidCallback? onBell; - - /// Called when the terminal title changes. Read [title] for the value. - VoidCallback? onTitleChanged; - - /// Called when the working directory changes. Read [pwd] for the value. - VoidCallback? onPwdChanged; - - /// Sets the callback for desktop notifications requested through OSC 9 or - /// OSC 777, or clears it if null. - /// - /// Requests are untrusted. The application decides whether and how to - /// display them. Fires synchronously during [write]. - set onDesktopNotification(ValueChanged? callback); - - /// Sets the callback for program progress reported through OSC 9;4, or - /// clears it if null. - /// - /// The application decides how to present progress. Fires synchronously - /// during [write]. - set onProgressReport(ValueChanged? callback); - - /// Called when the grid dimensions change. Forward to your backend. - OnResize? onResize; - - /// Creates a controller with the given [config]. - /// - /// The terminal is created immediately with dimensions and scrollback - /// from [config]. Disposed when the controller is disposed. - factory TerminalController({TerminalConfig config}) = TerminalControllerImpl; - - @internal - TerminalController.base(); - - /// Active screen buffer (primary or alternate). - /// - /// Full-screen programs (vim, less, htop) use the alternate screen. - /// Scrollback is only available on the primary screen. - TerminalScreen get activeScreen; - - /// Current terminal configuration. - TerminalConfig get config; - - /// Replaces the configuration. - /// - /// Applies mode and encoder changes without recreating the terminal. - /// Screen content, scrollback, and cursor position are preserved. - set config(TerminalConfig config); - - /// Whether the terminal currently has an active text selection. - bool get hasSelection; - - /// Current soft keyboard state. - KeyboardState get keyboardState; - - /// Current mouse tracking mode requested by the terminal program. - /// - /// When active, mouse events are encoded and sent to the program - /// instead of performing selection. Hold Shift to bypass. - MouseTracking get mouseTracking; - - /// Handles a clipboard write requested by terminal content. - /// - /// Requests are ignored when this is null. The callback receives every binary - /// representation and destination so applications can apply their own - /// security and platform policy without losing protocol capabilities. It - /// fires synchronously during [write]. - /// - /// ```dart - /// controller.onClipboardWrite = (write) { - /// if (write.location != .standard) return .denied; - /// return appClipboard.write(write); - /// }; - /// ``` - set onClipboardWrite(ClipboardWriteCallback? callback); - - /// Working directory reported by the shell (OSC 7). Empty if unset. - String get pwd; - - /// Number of scrollback rows above the viewport. - int get scrollbackRows; - - /// Scrollbar state: total rows, visible rows, and current offset. - Scrollbar get scrollbar; - - /// Terminal title set by the running program. - String get title; - - /// Total rows: viewport plus scrollback. - int get totalRows; - - /// Virtual modifier keys for on-screen keyboard UIs. - /// - /// Merged with physical modifiers when encoding input. Cleared - /// automatically after [sendKey] or [sendText] produces output. - /// - /// ```dart - /// controller.toggleMod(const Mods.ctrl()); - /// controller.sendKey(Key.c); // Sends Ctrl+C, clears the mod. - /// ``` - Mods get virtualMods; - - /// Clears scrollback and sends a form feed via [onOutput]. - /// - /// No-op on the alternate screen. - void clear(); - - /// Clears the current selection. - void clearSelection(); - - /// Clears all virtual modifiers. - void clearVirtualMods(); - - /// Creates a [Formatter] for extracting terminal content. - /// - /// Supports plain text, HTML, and VT sequence output via [format]. - /// Set [unwrap] to join soft-wrapped lines, [trim] to strip trailing - /// whitespace. - Formatter createFormatter({ - required FormatterFormat format, - bool unwrap = false, - bool trim = false, - FormatterExtra extra = const FormatterExtra(), - }); - - /// Hides the soft keyboard and keeps it hidden. - /// - /// Stays hidden until [showKeyboard] is called. Focus changes alone - /// will not re-show it. - void disableKeyboard(); - - /// Hides the soft keyboard. Re-shows on next focus gain. - void hideKeyboard(); - - /// Returns the live value of a terminal [mode]. - /// - /// May differ from [config] if the running program changed it. - bool modeGet(TerminalMode mode); - - /// Sets a terminal [mode] at runtime. - /// - /// Not persisted in [config]. May be overwritten when the terminal - /// restores modes (e.g. exiting the alternate screen). - void modeSet(TerminalMode mode, {required bool value}); - - /// Sends paste data to the terminal via [onOutput]. - /// - /// Wraps the text in bracketed paste sequences when the terminal - /// has bracketed paste mode enabled. Scrolls to bottom based on - /// [TerminalConfig.scrollToBottom] policy. - void paste(String text); - - /// Requests keyboard focus for the attached [TerminalView]. - void requestFocus(); - - /// Scrolls the viewport to the bottom (most recent content). - void scrollToBottom(); - - /// Scrolls the viewport to the top of the scrollback history. - void scrollToTop(); - - /// Selects all terminal content including scrollback. - void selectAll(); - - /// Returns the text within the current selection, or empty string when - /// there is no selection. - /// - /// [format] controls the output encoding: - /// - [FormatterFormat.plain]: unstyled text, suitable for the clipboard - /// (default). - /// - [FormatterFormat.vt]: VT escape sequences preserving colors, styles, - /// and hyperlinks. - /// - [FormatterFormat.html]: HTML with inline styles. - /// - /// In normal selection mode, soft-wrapped lines are joined into a single - /// line without an inserted newline. In block mode, every row is kept - /// separate regardless of wrapping. - String selectedText({FormatterFormat format = .plain}); - - /// Selects the inclusive range between two terminal cells. - /// - /// Coordinates are interpreted in [pointTag]. Use [PointTag.screen] for - /// rows counted from the top of scrollback through the active screen, or - /// [PointTag.viewport] for currently visible rows. When [rectangle] is - /// true, the endpoints describe opposite corners of a block selection. - void selectRange({ - required Position start, - required Position end, - PointTag pointTag = .screen, - bool rectangle = false, - }); - - /// Encodes a key press and sends it via [onOutput]. - /// - /// [mods] are merged with [virtualMods]. Virtual modifiers are cleared - /// after output is produced. - void sendKey(Key key, {Mods mods = const Mods.none()}); - - /// Sends literal UTF-8 text via [onOutput]. - /// - /// No key encoding is applied. Use [sendKey] for individual key - /// presses that need proper escape sequence encoding. - void sendText(String text); - - /// Shows the soft keyboard and re-enables it if disabled. - void showKeyboard(); - - /// Toggles a virtual modifier on or off. - void toggleMod(Mods mod); - - /// Removes keyboard focus from the attached [TerminalView]. - void unfocus(); - - /// Feeds raw bytes from the backend into the terminal. - /// - /// Call this with data received from your PTY, SSH channel, or socket. - /// The terminal processes the bytes and may call [onOutput] with - /// response data (e.g. for device attribute queries). - void write(Uint8List data); -} diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart deleted file mode 100644 index 3ae8899e..00000000 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ /dev/null @@ -1,1070 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart' - show VoidCallback, defaultTargetPlatform, kIsWeb; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:libghostty/libghostty.dart' as vt; -import 'package:libghostty/libghostty.dart' hide KeyEvent; -import 'package:meta/meta.dart'; - -import '../foundation.dart'; -import '../rendering/kitty_png_decoder.dart'; -import 'compression_scheduler.dart'; -import 'selection_gesture_driver.dart'; -import 'terminal_controller.dart'; -import 'terminal_input_client.dart'; -import 'terminal_view_binding.dart'; - -@internal -class TerminalControllerImpl extends TerminalController - implements TerminalViewBinding { - static const _cr = 0x0d; - static const _del = 0x7f; - static const _formFeed = 0x0c; - static const _space = 0x20; - static const _macFunctionKeyStart = 0xF700; - static const _macFunctionKeyEnd = 0xF8FF; - - static final _crBytes = Uint8List.fromList([_cr]); - static final _formFeedBytes = Uint8List.fromList([_formFeed]); - static final _clearScrollback = utf8.encode('\x1b[3J'); - static final _appCursorDown = Uint8List.fromList([0x1b, 0x4f, 0x42]); - static final _appCursorUp = Uint8List.fromList([0x1b, 0x4f, 0x41]); - static final _cursorDown = Uint8List.fromList([0x1b, 0x5b, 0x42]); - static final _cursorUp = Uint8List.fromList([0x1b, 0x5b, 0x41]); - - @override - final Terminal terminal; - final _renderState = RenderState(); - final _keyEncoder = KeyEncoder(); - final _mouseEncoder = MouseEncoder(); - late final SelectionGestureDriver _selectionGesture; - final vt.KeyEvent _keyEvent; - final MouseEvent _mouseEvent; - final TerminalInputClient _textInput; - late final CompressionScheduler _compressionScheduler; - - TerminalConfig _config; - TerminalScreen _activeScreen = .primary; - MouseTracking _mouseTracking = .none; - KeyboardState _keyboardState = .hidden; - Mods _virtualMods = const .none(); - ClipboardWriteCallback? _onClipboardWrite; - var _preeditText = ''; - var _cursorKeyApplication = false; - Brightness _brightness = .dark; - var _cursorBlinking = true; - var _wasFocused = false; - var _selectionMutationDepth = 0; - - CellMetrics _lastMetrics = const .new( - cellWidth: 0, - cellHeight: 0, - baseline: 0, - ); - var _lastDevicePixelRatio = 1.0; - - FocusNode? _focusNode; - ScrollController? _scrollController; - var _lastCols = 0; - var _lastRows = 0; - - TerminalControllerImpl({ - TerminalConfig config = const TerminalConfig(), - @visibleForTesting void Function(VoidCallback)? scheduleCompressionIdle, - }) : _config = config, - _keyEvent = vt.KeyEvent(), - _mouseEvent = MouseEvent(), - _textInput = TerminalInputClient(), - terminal = Terminal(cols: config.cols, rows: config.rows), - super.base() { - _selectionGesture = SelectionGestureDriver(terminal); - _compressionScheduler = CompressionScheduler( - readActivity: () => terminal.compressionActivity, - compress: terminal.compress, - scheduleIdle: scheduleCompressionIdle, - ); - installDefaultKittyPngDecoder(); - _textInput - ..onTextCommitted = _handleTextCommitted - ..onDelete = _handleDelete - ..onPreeditChanged = _handlePreeditChanged - ..onNewline = _handleNewline; - _wireTerminalCallbacks(); - _applyModes(); - _applyTerminalOptions(); - terminal.addListener(_onTerminalChanged); - } - - @override - TerminalScreen get activeScreen => terminal.activeScreen; - - @override - set brightness(Brightness value) { - _textInput.keyboardAppearance = value; - _brightness = value; - } - - @override - TerminalConfig get config => _config; - - @override - set config(TerminalConfig value) { - if (_config == value) return; - _config = value; - _applyModes(); - _applyTerminalOptions(); - _wireTerminalCallbacks(); - notifyListeners(); - } - - @override - bool get cursorBlinks { - if (!_cursorBlinking || !hasFocus) return false; - if (_activeScreen == .alternate) return true; - final scrollController = _scrollController; - if (scrollController == null || !scrollController.hasClients) { - return terminal.isViewportActive; - } - final position = scrollController.position; - if (!position.hasContentDimensions) return terminal.isViewportActive; - return position.pixels >= position.maxScrollExtent - 1.0; - } - - @override - bool get hasFocus => _focusNode?.hasFocus ?? false; - - @override - bool get hasSelection => terminal.selection != null; - - @override - KeyboardState get keyboardState => _keyboardState; - - @override - MouseTracking get mouseTracking => _mouseTracking; - - @override - set onClipboardWrite(ClipboardWriteCallback? value) { - if (identical(_onClipboardWrite, value)) return; - _onClipboardWrite = value; - terminal.onClipboardWrite = value; - } - - @override - set onDesktopNotification(ValueChanged? value) => - terminal.onDesktopNotification = value; - - @override - set onProgressReport(ValueChanged? value) => - terminal.onProgressReport = value; - - @override - String get preeditText => _preeditText; - - @override - String get pwd => terminal.pwd; - - @override - int get scrollbackRows => terminal.scrollbackRows; - - @override - Scrollbar get scrollbar => terminal.scrollbar; - - @override - String get title => terminal.title; - - @override - int get totalRows => terminal.totalRows; - - @override - Mods get virtualMods => _virtualMods; - - bool get _hasActiveComposition => - _textInput.hasActiveComposition || _preeditText.isNotEmpty; - - bool get _isDesktopPlatform { - if (kIsWeb) return false; - return switch (defaultTargetPlatform) { - .linux || .macOS || .windows => true, - .android || .fuchsia || .iOS => false, - }; - } - - bool get _shouldForwardCompositionKeyToTextInput { - return _hasActiveComposition && _textInput.isAttached && _isDesktopPlatform; - } - - @override - void attach( - FocusNode focusNode, - ScrollController scrollController, { - required int viewId, - }) { - _focusNode?.removeListener(_onFocusChanged); - _focusNode = focusNode; - _wasFocused = focusNode.hasFocus; - _focusNode!.addListener(_onFocusChanged); - _textInput - ..viewId = viewId - ..keyboardAppearance = _brightness; - if (_wasFocused && _keyboardState != .disabled) { - if (_keyboardState == .showing) { - _textInput.show(); - } else { - _textInput.ensureAttached(keyboardAppearance: _brightness); - } - } - _scrollController = scrollController; - if (scrollController.hasClients) _compressionScheduler.schedule(); - } - - @override - void cancelSelectionGesture() { - _selectionGesture.reset(); - _setSelection(null, clearIfNull: true); - } - - @override - void clear() { - if (_activeScreen == .alternate) return; - clearSelection(); - terminal.write(_clearScrollback); - _emitOutput(_formFeedBytes); - } - - @override - void clearSelection() => _clearSelection(notify: true); - - @override - void clearVirtualMods() { - if (_virtualMods.isEmpty) return; - _virtualMods = const .none(); - notifyListeners(); - } - - @override - Formatter createFormatter({ - required FormatterFormat format, - bool unwrap = false, - bool trim = false, - FormatterExtra extra = const FormatterExtra(), - }) { - return Formatter( - terminal: terminal, - format: format, - unwrap: unwrap, - trim: trim, - extra: extra, - ); - } - - @override - void detach() { - _compressionScheduler.cancel(); - _focusNode?.removeListener(_onFocusChanged); - _focusNode = null; - _wasFocused = false; - _keyboardState = .hidden; - _preeditText = ''; - _scrollController = null; - _textInput.detach(); - } - - @override - void disableKeyboard() => _updateKeyboardState(.disabled); - - @override - void dispose() { - _compressionScheduler.dispose(); - terminal.removeListener(_onTerminalChanged); - detach(); - _keyEvent.dispose(); - _mouseEvent.dispose(); - _selectionGesture.dispose(); - _keyEncoder.dispose(); - _mouseEncoder.dispose(); - _renderState.dispose(); - terminal.dispose(); - super.dispose(); - } - - @override - KeyEventResult handleKeyEvent(KeyEvent event) { - if (!_hasActiveComposition && - (event is KeyDownEvent || event is KeyRepeatEvent) && - HardwareKeyboard.instance.isShiftPressed && - terminal.selection != null) { - if (_extendSelection(event.logicalKey)) return .handled; - } - - final key = keyFromPhysical(event.physicalKey); - final KeyAction? action = switch (event) { - KeyDownEvent() => .press, - KeyUpEvent() => .release, - KeyRepeatEvent() => .repeat, - _ => null, - }; - - if (action == null) return .ignored; - - if (_shouldForwardCompositionKeyToTextInput) { - return .skipRemainingHandlers; - } - - final unshiftedCodepoint = unshiftedCodepointForKey(key); - final mods = _currentMods(); - final character = _encoderCharacter(event.character); - final consumedMods = _consumedModsFor( - character, - unshiftedCodepoint: unshiftedCodepoint, - mods: mods, - ); - - _keyEvent - ..key = key - ..mods = mods - ..action = action - ..utf8 = character - ..consumedMods = consumedMods - ..unshiftedCodepoint = unshiftedCodepoint - ..composing = _hasActiveComposition; - - _keyEncoder.sync(terminal); - final result = _keyEncoder.encode(_keyEvent); - if (result.isEmpty) return _hasActiveComposition ? .handled : .ignored; - - if (_shouldRouteKeyThroughTextInput( - action: action, - character: character, - encoded: result, - mods: mods, - )) { - _onTextInput(); - return .skipRemainingHandlers; - } - - clearVirtualMods(); - final forwardToPlatformIme = _consumeCommittedCompositionEditKey( - key, - action, - mods, - ); - _emitOutput(utf8.encode(result)); - _onTextInput(); - - return forwardToPlatformIme ? .skipRemainingHandlers : .handled; - } - - @override - void handleMouseEvent(TerminalMouseEvent event) { - _mouseEvent - ..action = event.action - ..button = event.button - ..mods = _currentMods() - ..setPosition( - x: event.pixelX * _lastDevicePixelRatio, - y: event.pixelY * _lastDevicePixelRatio, - ); - _mouseEncoder.sync(terminal); - final result = _mouseEncoder.encode(_mouseEvent); - if (result.isEmpty) return; - _emitOutput(utf8.encode(result)); - } - - @override - void handleResize({ - required int cols, - required int rows, - required CellMetrics metrics, - required EdgeInsets padding, - required double devicePixelRatio, - }) { - _lastCols = cols; - _lastRows = rows; - _lastMetrics = metrics; - _lastDevicePixelRatio = devicePixelRatio; - final cellWidthPx = (metrics.cellWidth * devicePixelRatio).round(); - final cellHeightPx = (metrics.cellHeight * devicePixelRatio).round(); - _mouseEncoder.setSize( - MouseEncoderSize( - screenWidth: cols * cellWidthPx, - screenHeight: rows * cellHeightPx, - cellWidth: cellWidthPx, - cellHeight: cellHeightPx, - paddingLeft: (padding.left * devicePixelRatio).round(), - paddingRight: (padding.right * devicePixelRatio).round(), - paddingTop: (padding.top * devicePixelRatio).round(), - paddingBottom: (padding.bottom * devicePixelRatio).round(), - ), - ); - onResize?.call(cols, rows); - - if (terminal.modeGet(const TerminalMode.inBandResize())) { - final report = SizeReportStyle.mode2048.encode( - rows: rows, - columns: cols, - cellWidth: cellWidthPx, - cellHeight: cellHeightPx, - ); - _emitOutput(utf8.encode(report)); - } - } - - @override - void handleScroll(int lines) { - if (_activeScreen != .alternate || lines == 0) return; - - if (_mouseTracking != .none) { - final button = lines < 0 ? MouseButton.four : MouseButton.five; - final count = lines.abs(); - - if (count > 0) _mouseEncoder.sync(terminal); - - for (var i = 0; i < count; i++) { - _mouseEvent - ..action = .press - ..button = button - ..mods = _currentMods() - ..setPosition(x: 0, y: 0); - final result = _mouseEncoder.encode(_mouseEvent); - if (result.isNotEmpty) _emitOutput(utf8.encode(result)); - } - return; - } - - final up = _cursorKeyApplication ? _appCursorUp : _cursorUp; - final down = _cursorKeyApplication ? _appCursorDown : _cursorDown; - final key = lines < 0 ? up : down; - final count = lines.abs(); - final bytes = Uint8List(key.length * count); - for (var i = 0; i < count; i++) { - bytes.setRange(i * key.length, (i + 1) * key.length, key); - } - _emitOutput(bytes); - } - - @override - void handleSelectionPress({ - required Position cell, - required Offset localPosition, - required TerminalGestureSettings settings, - }) { - final ref = _viewportRef(cell); - if (ref == null) { - _setSelection(null, clearIfNull: true); - return; - } - - var selection = _selectionGesture.press( - ref: ref, - localPosition: localPosition, - settings: settings, - ); - if (selection != null && - settings.lineSelectMode == .full && - _selectionGesture.behavior == .line) { - selection = _fullWidthLineSelection(selection); - } - _setSelection(selection, clearIfNull: true); - } - - @override - void handleSelectionRelease(Position cell) { - _setSelection(_selectionGesture.release(_viewportRef(cell))); - } - - @override - void handleViewportChanged() { - if (_scrollController?.hasClients ?? false) { - _compressionScheduler.notifyActivity(); - } - } - - @override - void hideKeyboard() => _updateKeyboardState(.hidden); - - @override - void invalidateSelection() => _clearSelection(notify: false); - - @override - bool modeGet(TerminalMode mode) => terminal.modeGet(mode); - - @override - void modeSet(TerminalMode mode, {required bool value}) { - terminal.modeSet(mode, value: value); - } - - @override - void paste(String text) { - if (text.isEmpty) return; - final bracketed = terminal.modeGet(const .bracketedPaste()); - _emitOutput(pasteEncode(text, bracketed: bracketed)); - _scrollToBottomOnInput(); - } - - @override - void requestFocus() => _focusNode?.requestFocus(); - - @override - void scrollToBottom() { - if (_activeScreen == .alternate) return; - final previousOffset = terminal.scrollbar.offset; - terminal.scrollToBottom(); - if (terminal.scrollbar.offset != previousOffset) handleViewportChanged(); - final controller = _scrollController; - if (controller != null && controller.hasClients) { - final max = controller.position.maxScrollExtent; - if (max.isFinite) controller.jumpTo(max); - } - } - - @override - void scrollToTop() { - if (_activeScreen == .alternate) return; - final previousOffset = terminal.scrollbar.offset; - terminal.scrollToTop(); - if (terminal.scrollbar.offset != previousOffset) handleViewportChanged(); - final controller = _scrollController; - if (controller != null && controller.hasClients) controller.jumpTo(0); - } - - @override - void selectAll() => _setSelection(terminal.selectAll()); - - @override - String selectedText({FormatterFormat format = .plain}) { - final selection = terminal.selection; - if (selection == null) return ''; - final formatted = terminal.formatSelection( - format: format, - unwrap: !selection.rectangle, - selection: selection, - ); - return formatted ?? ''; - } - - @override - void selectRange({ - required Position start, - required Position end, - PointTag pointTag = .screen, - bool rectangle = false, - }) { - _setSelection( - .fromRefs( - start: .at(terminal, start, pointTag: pointTag), - end: .at(terminal, end, pointTag: pointTag), - rectangle: rectangle, - ), - ); - } - - @override - void sendKey(vt.Key key, {Mods mods = const .none()}) { - final effectiveMods = mods | _virtualMods; - final codepoint = unshiftedCodepointForKey(key); - _keyEvent - ..key = key - ..mods = effectiveMods - ..action = .press - ..consumedMods = const .none() - ..unshiftedCodepoint = codepoint - ..utf8 = codepoint > 0 ? String.fromCharCode(codepoint) : null - ..composing = false; - - _keyEncoder.sync(terminal); - final result = _keyEncoder.encode(_keyEvent); - if (result.isEmpty) return; - _emitOutput(utf8.encode(result)); - clearVirtualMods(); - } - - @override - void sendText(String text) { - if (text.isEmpty) return; - _emitOutput(utf8.encode(text)); - clearVirtualMods(); - } - - @override - void showKeyboard() => _updateKeyboardState(.showing); - - @override - void toggleMod(Mods mod) { - _virtualMods = _virtualMods ^ mod; - notifyListeners(); - } - - @override - void unfocus() => _focusNode?.unfocus(); - - @override - void updateSelectionAutoscroll({ - required Position cell, - required Offset localPosition, - required bool rectangle, - }) { - if (_lastCols <= 0 || _lastRows <= 0) return; - _setSelection( - _selectionGesture.autoscroll( - cell: _clampViewportPoint(cell), - localPosition: localPosition, - rectangle: rectangle, - geometry: _selectionGestureGeometry(), - ), - ); - _syncScrollControllerToTerminal(); - } - - @override - void updateSelectionDrag({ - required Position cell, - required Offset localPosition, - required bool rectangle, - }) { - final ref = _viewportRef(cell); - if (ref == null) return; - _setSelection( - _selectionGesture.drag( - ref: ref, - localPosition: localPosition, - rectangle: rectangle, - geometry: _selectionGestureGeometry(), - ), - ); - } - - @override - void updateTextInputGeometry({ - required Size editableSize, - required Matrix4 transform, - required Rect caretRect, - required Rect composingRect, - }) { - _textInput.updateGeometry( - editableSize: editableSize, - transform: transform, - caretRect: caretRect, - composingRect: composingRect, - ); - } - - @override - void write(Uint8List data) => terminal.write(data); - - void _applyModes() { - for (final entry in _config.modes.entries) { - terminal.modeSet(entry.key, value: entry.value); - } - } - - void _applyTerminalOptions() { - terminal.scrollbackMaxBytes = _config.scrollbackMaxBytes; - terminal.scrollbackMaxLines = _config.scrollbackMaxLines; - terminal.kittyImageStorageLimit = _config.kittyImageStorageLimit; - terminal.setApcBufferLimit(_config.apcBufferLimit); - terminal.setGlyphProtocol(enabled: _config.glyphProtocol); - terminal.defaultCursorShape = _config.cursorStyle; - terminal.defaultCursorBlink = _config.cursorBlink; - _cursorBlinking = _effectiveCursorBlinking(); - } - - int _clampInt(int value, int min, int max) { - if (value < min) return min; - if (value > max) return max; - return value; - } - - Position _clampViewportPoint(Position position) { - return Position( - row: _clampInt(position.row, 0, _lastRows - 1), - col: _clampInt(position.col, 0, _lastCols - 1), - ); - } - - void _clearSelection({required bool notify}) { - if (terminal.selection == null) return; - _selectionGesture.reset(); - _mutateSelection(() => terminal.selection = null); - if (notify) super.notifyListeners(); - } - - bool _consumeCommittedCompositionEditKey( - vt.Key key, - KeyAction action, - Mods mods, - ) { - // A plain deletion immediately after a desktop candidate commit belongs - // to the platform IME first. Modified deletions stay terminal-only so - // protocol modes and shell shortcuts keep their encoded semantics. - if (!_isDesktopPlatform) return false; - if (action != .press && action != .repeat) return false; - if (key != .backspace && key != .delete) return false; - if (!mods.isEmpty) return false; - return _textInput.consumeCommittedCompositionEdit(); - } - - Mods _consumedModsFor( - String? character, { - required int unshiftedCodepoint, - required Mods mods, - }) { - // Flutter does not expose consumed modifiers, so this fallback only - // accounts for Shift producing a different single-codepoint character. - if (!mods.hasShift || character == null || unshiftedCodepoint == 0) { - return const .none(); - } - - final codepoints = character.runes.iterator; - if (!codepoints.moveNext()) return const .none(); - final codepoint = codepoints.current; - if (codepoints.moveNext()) return const .none(); - if (codepoint == unshiftedCodepoint) return const .none(); - return const .shift(); - } - - Mods _currentMods() { - var mods = _virtualMods; - final keyboard = HardwareKeyboard.instance; - if (keyboard.isShiftPressed) mods = mods | const .shift(); - if (keyboard.isControlPressed) mods = mods | const .ctrl(); - if (keyboard.isAltPressed) mods = mods | const .alt(); - if (keyboard.isMetaPressed) mods = mods | const .superKey(); - return mods; - } - - bool _effectiveCursorBlinking() { - return _config.cursorBlink ?? terminal.modeGet(const .cursorBlinking()); - } - - bool _emitKeyPress( - vt.Key key, { - Mods mods = const .none(), - bool clearMods = true, - }) { - final codepoint = unshiftedCodepointForKey(key); - _keyEvent - ..key = key - ..mods = mods - ..action = .press - ..consumedMods = const .none() - ..unshiftedCodepoint = codepoint - ..utf8 = codepoint > 0 ? String.fromCharCode(codepoint) : null - ..composing = false; - - _keyEncoder.sync(terminal); - final result = _keyEncoder.encode(_keyEvent); - if (result.isEmpty) return false; - - _emitOutput(utf8.encode(result)); - if (clearMods) clearVirtualMods(); - return true; - } - - void _emitOutput(Uint8List bytes) => onOutput?.call(bytes); - - void _ensureGridSize() { - if (_lastRows > 0 && _lastCols > 0) return; - _renderState.update(terminal); - _lastRows = _renderState.rows; - _lastCols = _renderState.cols; - } - - bool _extendSelection(LogicalKeyboardKey arrowKey) { - final SelectionAdjust? adjustment = switch (arrowKey) { - .arrowRight => .right, - .arrowLeft => .left, - .arrowUp => .up, - .arrowDown => .down, - _ => null, - }; - if (adjustment == null) return false; - final selection = terminal.selection; - if (selection == null) return false; - _setSelection(selection.adjust(adjustment)); - return true; - } - - Selection _fullWidthLineSelection(Selection selection) { - final start = selection.start.positionIn(.viewport); - final end = selection.end.positionIn(.viewport); - if (start == null || end == null) return selection; - _ensureGridSize(); - if (_lastCols <= 0) return selection; - return Selection.fromRefs( - start: .at( - terminal, - Position(row: start.row, col: 0), - pointTag: .viewport, - ), - end: .at( - terminal, - Position(row: end.row, col: _lastCols - 1), - pointTag: .viewport, - ), - ); - } - - void _handleDelete(int count) { - if (count <= 0) return; - - var emitted = false; - for (var i = 0; i < count; i++) { - emitted = - _emitKeyPress(.backspace, mods: _currentMods(), clearMods: false) || - emitted; - } - if (!emitted) return; - - clearVirtualMods(); - _onTextInput(); - } - - void _handleNewline() { - _emitOutput(_crBytes); - clearVirtualMods(); - _onTextInput(); - } - - void _handlePreeditChanged(String text) { - if (_preeditText == text) return; - _preeditText = text; - if (text.isNotEmpty) _onTextInput(); - notifyListeners(); - } - - void _handlePwdChanged() { - onPwdChanged?.call(); - notifyListeners(); - } - - TerminalSizeInfo _handleSizeQuery() { - _renderState.update(terminal); - return TerminalSizeInfo( - rows: _renderState.rows, - columns: _renderState.cols, - cellWidth: (_lastMetrics.cellWidth * _lastDevicePixelRatio).round(), - cellHeight: (_lastMetrics.cellHeight * _lastDevicePixelRatio).round(), - ); - } - - void _handleTextCommitted(String text) { - if (_virtualMods.isEmpty) { - _emitOutput(utf8.encode(text)); - _onTextInput(); - return; - } - - if (text.length == 1) { - final key = keyFromCodepoint(text.codeUnitAt(0)); - if (key != null) { - sendKey(key); - return; - } - } - - _emitOutput(utf8.encode(text)); - clearVirtualMods(); - _onTextInput(); - } - - void _mutateSelection(void Function() mutate) { - _selectionMutationDepth++; - try { - mutate(); - } finally { - _selectionMutationDepth--; - } - } - - void _onFocusChanged() { - final focused = _focusNode?.hasFocus ?? false; - if (focused == _wasFocused) return; - _wasFocused = focused; - - if (focused && _keyboardState == .showing) { - _textInput.show(); - } else if (focused && _keyboardState != .disabled) { - _textInput.ensureAttached(keyboardAppearance: _brightness); - } else if (!focused) { - if (_keyboardState == .showing) _keyboardState = .hidden; - _textInput.hide(); - } - - if (!focused) clearVirtualMods(); - - if (terminal.modeGet(const TerminalMode.focusEvent())) { - final event = focused ? FocusEvent.gained : FocusEvent.lost; - _emitOutput(utf8.encode(event.encode())); - } - - notifyListeners(); - } - - void _onTerminalChanged() { - if (_scrollController?.hasClients ?? false) { - _compressionScheduler.notifyActivity(); - } - var changed = false; - - final newMouseTracking = terminal.mouseTracking; - if (newMouseTracking != _mouseTracking) { - _mouseTracking = newMouseTracking; - changed = true; - } - - final newActiveScreen = terminal.activeScreen; - if (newActiveScreen != _activeScreen) { - _activeScreen = newActiveScreen; - if (newActiveScreen == .primary) _applyModes(); - changed = true; - } - - final newCursorKeyApp = terminal.modeGet(const .cursorKeys()); - if (newCursorKeyApp != _cursorKeyApplication) { - _cursorKeyApplication = newCursorKeyApp; - changed = true; - } - - final newCursorBlinking = _effectiveCursorBlinking(); - if (newCursorBlinking != _cursorBlinking) { - _cursorBlinking = newCursorBlinking; - changed = true; - } - - // terminal.selection uses the same synchronous listener path as output. - // Controller-owned selection changes must preserve the scrollback viewport. - if (_selectionMutationDepth == 0) _scrollToBottomOnOutput(); - if (changed) notifyListeners(); - } - - void _onTextInput() { - if (_config.selectionClearOnTyping) clearSelection(); - _scrollToBottomOnInput(); - } - - void _scrollToBottomOnInput() { - if (_activeScreen == .alternate) return; - final policy = _config.scrollToBottom; - if (policy == .onKeystroke || policy == .both) scrollToBottom(); - } - - void _scrollToBottomOnOutput() { - if (_activeScreen == .alternate) return; - final policy = _config.scrollToBottom; - if (policy == .onOutput || policy == .both) scrollToBottom(); - } - - SelectionGestureGeometry _selectionGestureGeometry() { - _ensureGridSize(); - return SelectionGestureGeometry( - columns: _lastCols <= 0 ? 1 : _lastCols, - cellWidth: _lastMetrics.cellWidth <= 0 - ? 1 - : _lastMetrics.cellWidth.round(), - paddingLeft: 0, - screenHeight: _lastMetrics.cellHeight <= 0 - ? 1 - : (_lastMetrics.cellHeight * (_lastRows <= 0 ? 1 : _lastRows)) - .round(), - ); - } - - void _setSelection(Selection? value, {bool clearIfNull = false}) { - if (value == null) { - if (!clearIfNull || terminal.selection == null) return; - _mutateSelection(() => terminal.selection = null); - notifyListeners(); - return; - } - - final current = terminal.selection; - if (current != null && current.equal(value)) return; - _mutateSelection(() => terminal.selection = value); - notifyListeners(); - } - - bool _shouldRouteKeyThroughTextInput({ - required KeyAction action, - required String? character, - required String encoded, - required Mods mods, - }) { - // Desktop printable keys are offered to Flutter text input only when the - // terminal encoder produced the same literal character. Any protocol, - // modifier, or composition-sensitive key stays on the terminal path. - if (encoded != character) return false; - if (_hasActiveComposition || !_textInput.isAttached) return false; - if (!_isDesktopPlatform) return false; - if (action != .press && action != .repeat) return false; - if (!_virtualMods.isEmpty) return false; - return !mods.hasCtrl && !mods.hasAlt && !mods.hasSuper; - } - - void _syncScrollControllerToTerminal() { - final scrollController = _scrollController; - if (scrollController == null || !scrollController.hasClients) return; - final target = terminal.scrollbar.offset * _lastMetrics.cellHeight; - final position = scrollController.position; - final clamped = target.clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if (clamped == position.pixels) return; - scrollController.jumpTo(clamped); - } - - Future _updateKeyboardState(KeyboardState newState) async { - if (newState == _keyboardState) return; - _keyboardState = newState; - - switch (newState) { - case .showing when hasFocus: - _focusNode?.requestFocus(); - _textInput.show(); - case .showing: - _focusNode?.requestFocus(); - case .hidden when hasFocus: - _textInput.hide(); - _textInput.ensureAttached(keyboardAppearance: _brightness); - case .hidden: - _textInput.hide(); - case .disabled: - _textInput.hide(); - } - - notifyListeners(); - } - - GridRef? _viewportRef(Position position) { - _ensureGridSize(); - if (_lastRows <= 0 || _lastCols <= 0) return null; - return .at(terminal, _clampViewportPoint(position), pointTag: .viewport); - } - - void _wireTerminalCallbacks() { - terminal.onWritePty = _emitOutput; - terminal.onBell = () => onBell?.call(); - terminal.onTitleChanged = () => onTitleChanged?.call(); - terminal.onPwdChanged = _handlePwdChanged; - terminal.onColorScheme = () => _brightness == .light ? .light : .dark; - terminal.onSize = _handleSizeQuery; - terminal.onDeviceAttributes = () => _config.deviceAttributes; - final enquiry = _config.enquiryResponse; - terminal.onEnquiry = enquiry.isEmpty - ? null - : () => .fromList(utf8.encode(enquiry)); - } - - /// Filters out control characters and macOS function key private-use - /// codepoints that should not be sent as UTF-8 text to the key encoder. - static String? _encoderCharacter(String? character) { - if (character == null || character.isEmpty) return null; - final code = character.codeUnitAt(0); - if (code < _space || code == _del) return null; - if (code >= _macFunctionKeyStart && code <= _macFunctionKeyEnd) return null; - return character; - } -} diff --git a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart deleted file mode 100644 index d11778ca..00000000 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ /dev/null @@ -1,350 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:libghostty/libghostty.dart' - show MouseAction, MouseTracking, Position; -import 'package:meta/meta.dart'; - -import '../foundation.dart'; -import '../links/link_settings.dart'; -import 'link_interaction.dart'; -import 'terminal_raw_gesture_detector.dart'; -import 'terminal_view_binding.dart'; - -/// Interprets gestures as terminal actions: selection, mouse tracking -/// reports, and focus requests. -/// -/// Reports all gestures to [TerminalViewBinding] which handles -/// snapping, scroll offset, and encoding. -@internal -class TerminalGestureDetector extends StatefulWidget { - final Widget child; - final int visibleRows; - final CellMetrics metrics; - final TerminalViewBinding binding; - final TerminalGestureSettings settings; - final LinkInteraction links; - final ValueChanged? onLinkActivate; - final ScrollController? scrollController; - - const TerminalGestureDetector({ - super.key, - required this.child, - this.visibleRows = 0, - required this.metrics, - required this.binding, - required this.links, - this.onLinkActivate, - this.scrollController, - this.settings = const TerminalGestureSettings(), - }); - - @override - State createState() => - _TerminalGestureDetectorState(); -} - -class _TerminalGestureDetectorState extends State { - _DragState? _drag; - Position? _pressCell; - var _linkPressActive = false; - Timer? _autoScrollTimer; - - TerminalViewBinding get _binding => widget.binding; - - @override - Widget build(BuildContext context) { - final tracked = _binding.mouseTracking != MouseTracking.none; - - return Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: tracked ? _handleTrackedDown : null, - onPointerMove: tracked ? _handleTrackedMove : null, - onPointerUp: tracked ? _handleTrackedUp : null, - child: TerminalRawGestureDetector( - onTapDown: _handleTapDown, - onTapUp: _handleTapUp, - onDragStart: _handleDragStart, - onDragUpdate: _handleDragUpdate, - onDragEnd: _handleDragEnd, - onLongPressStart: _handleLongPressStart, - onLongPressMoveUpdate: _handleLongPressMoveUpdate, - onLongPressUp: _handleLongPressUp, - child: widget.child, - ), - ); - } - - @override - void didUpdateWidget(TerminalGestureDetector oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.metrics != oldWidget.metrics || - widget.binding != oldWidget.binding) { - _binding.invalidateSelection(); - _stopAutoScroll(); - _drag = null; - _pressCell = null; - _cancelLinkPress(); - } - } - - @override - void dispose() { - _autoScrollTimer?.cancel(); - super.dispose(); - } - - void _autoScrollTick(Timer timer) { - final scrollController = widget.scrollController; - if (scrollController == null || !scrollController.hasClients) return; - - final drag = _drag; - if (drag == null) { - _stopAutoScroll(); - return; - } - - _binding.updateSelectionAutoscroll( - cell: drag.cell, - localPosition: drag.localPosition, - rectangle: drag.lastRectangle, - ); - } - - void _cancelLinkPress() { - if (!_linkPressActive) return; - _linkPressActive = false; - widget.links.cancel(); - } - - void _cancelSelectionPress() { - if (_pressCell == null) return; - _binding.cancelSelectionGesture(); - _pressCell = null; - } - - int _clampInt(int value, int min, int max) { - if (value < min) return min; - if (value > max) return max; - return value; - } - - void _endDrag() { - final drag = _drag; - if (drag != null) { - _releaseSelectionPress(drag.cell); - } else { - _releaseSelectionPress(); - } - _stopAutoScroll(); - _drag = null; - _cancelLinkPress(); - } - - void _handleDragEnd() => _endDrag(); - - void _handleDragStart(DragStartDetails details) { - _binding.requestFocus(); - _cancelLinkPress(); - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - if (!widget.settings.dragSelection) { - _cancelSelectionPress(); - return; - } - - _startDrag(details.localPosition, beginPress: _pressCell == null); - } - - void _handleDragUpdate(DragUpdateDetails details) { - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - if (_drag != null) _updateDrag(details.localPosition); - } - - void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - if (_drag != null) _updateDrag(details.localPosition); - } - - void _handleLongPressStart(LongPressStartDetails details) { - _binding.requestFocus(); - if (!widget.settings.longPressSelection) { - _cancelSelectionPress(); - return; - } - _startDrag( - details.localPosition, - rectangle: widget.settings.longPressSelectionShape == .rectangle, - beginPress: _pressCell == null, - ); - } - - void _handleLongPressUp() => _endDrag(); - - void _handleSelectionPress(Offset position) { - final cell = widget.metrics.cellAt(position); - _binding.handleSelectionPress( - cell: cell, - localPosition: position, - settings: widget.settings, - ); - _pressCell = cell; - } - - void _handleTapDown(TapDownDetails details) { - _binding.requestFocus(); - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - if (widget.links.handlePress( - localPosition: details.localPosition, - metrics: widget.metrics, - pointerKind: details.kind ?? .mouse, - virtualMods: _binding.virtualMods, - )) { - _linkPressActive = true; - _cancelSelectionPress(); - return; - } - _handleSelectionPress(details.localPosition); - } - - void _handleTapUp(TapUpDetails details) { - if (_linkPressActive) { - _linkPressActive = false; - final link = widget.links.handleRelease( - localPosition: details.localPosition, - metrics: widget.metrics, - ); - if (link != null) widget.onLinkActivate?.call(link); - return; - } - if (_pressCell == null && - _isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { - return; - } - _releaseSelectionPress(widget.metrics.cellAt(details.localPosition)); - } - - void _handleTrackedDown(PointerDownEvent event) { - final shift = - event.buttons & kSecondaryButton != 0 || - HardwareKeyboard.instance.isShiftPressed; - if (!_isMouseTracked(shift)) return; - _sendMouseEvent(.press, event.localPosition); - } - - void _handleTrackedMove(PointerMoveEvent event) { - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.motion, event.localPosition); - } - - void _handleTrackedUp(PointerUpEvent event) { - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.release, event.localPosition); - } - - bool _isBlockModifierPressed() { - final modifier = widget.settings.blockSelectionModifier; - if (modifier == null) return false; - final keyboard = HardwareKeyboard.instance; - final mods = _binding.virtualMods; - return switch (modifier) { - .alt => keyboard.isAltPressed || mods.hasAlt, - .meta => keyboard.isMetaPressed || mods.hasSuper, - .shift => keyboard.isShiftPressed || mods.hasShift, - .control => keyboard.isControlPressed || mods.hasCtrl, - }; - } - - bool _isMouseTracked(bool shift) { - return _binding.mouseTracking != .none && - !shift && - !_binding.virtualMods.hasShift; - } - - void _releaseSelectionPress([Position? cell]) { - cell ??= _pressCell; - if (cell == null) return; - _binding.handleSelectionRelease(cell); - _pressCell = null; - } - - void _sendMouseEvent(MouseAction action, Offset position) { - _binding.handleMouseEvent(( - action: action, - button: .left, - pixelX: position.dx, - pixelY: position.dy, - )); - } - - void _startAutoScroll() { - if (_autoScrollTimer != null) return; - _autoScrollTimer = Timer.periodic( - const Duration(milliseconds: 50), - _autoScrollTick, - ); - } - - void _startDrag( - Offset position, { - bool rectangle = false, - bool beginPress = false, - }) { - final cell = widget.metrics.cellAt(position); - final block = rectangle || _isBlockModifierPressed(); - _drag = _DragState(cell, position, baseRectangle: block); - if (beginPress) _handleSelectionPress(position); - } - - void _stopAutoScroll() { - _autoScrollTimer?.cancel(); - _autoScrollTimer = null; - } - - void _updateDrag(Offset position) { - final drag = _drag; - if (drag == null) return; - final cell = widget.metrics.cellAt(position); - drag.cell = cell; - drag.localPosition = position; - - final visibleRows = widget.visibleRows; - if (visibleRows > 0) { - if (cell.row < 0) { - _startAutoScroll(); - } else if (cell.row >= visibleRows) { - _startAutoScroll(); - } else { - _stopAutoScroll(); - } - } - - final clampedRow = visibleRows > 0 - ? _clampInt(cell.row, 0, visibleRows - 1) - : cell.row; - final clampedCell = Position(row: clampedRow, col: cell.col); - final rectangle = drag.baseRectangle || _isBlockModifierPressed(); - if (clampedCell == drag.lastCell && rectangle == drag.lastRectangle) { - return; - } - drag.lastCell = clampedCell; - drag.lastRectangle = rectangle; - - _binding.updateSelectionDrag( - cell: clampedCell, - localPosition: position, - rectangle: rectangle, - ); - } -} - -class _DragState { - Position cell; - Offset localPosition; - final bool baseRectangle; - bool lastRectangle; - Position? lastCell; - - _DragState(this.cell, this.localPosition, {required this.baseRectangle}) - : lastRectangle = baseRectangle; -} diff --git a/packages/flterm/lib/src/widgets/terminal_view_binding.dart b/packages/flterm/lib/src/widgets/terminal_view_binding.dart deleted file mode 100644 index e809c098..00000000 --- a/packages/flterm/lib/src/widgets/terminal_view_binding.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:libghostty/libghostty.dart' hide KeyEvent; - -import '../foundation.dart'; - -/// Internal contract between the controller and the view. -/// -/// The controller implements this. The view casts the controller to -/// this type to report user interactions and access internal state. -@internal -abstract interface class TerminalViewBinding { - /// Sets the theme brightness for color scheme queries and text input. - set brightness(Brightness value); - - /// Whether the cursor should actively blink right now. - /// - /// Combines DEC mode 12, focus state, and viewport scroll position. - /// True only when the terminal has focus, cursor blinking is enabled, - /// and the cursor row is visible in the viewport. - bool get cursorBlinks; - - /// Current mouse tracking mode. - MouseTracking get mouseTracking; - - /// Current IME preedit text that has not been committed to the terminal. - String get preeditText; - - /// The terminal instance for the renderer. - Terminal get terminal; - - /// Active virtual modifier keys. - Mods get virtualMods; - - /// Subscribes to focus and scroll changes in the owning Flutter view. - void attach( - FocusNode focusNode, - ScrollController scrollController, { - required int viewId, - }); - - /// Cancels the active selection gesture. - void cancelSelectionGesture(); - - /// Clears the current selection. - void clearSelection(); - - /// Unsubscribes focus, detaches text input, cleans up all state. - void detach(); - - /// Handles a keyboard event including selection extension, terminal - /// encoding, selection clearing, and scroll-to-bottom. - /// - /// Returns [KeyEventResult.handled] if the event produced terminal - /// output or extended a selection, [KeyEventResult.ignored] otherwise. - KeyEventResult handleKeyEvent(KeyEvent event); - - /// Reports a mouse event. Controller encodes and emits via onOutput. - void handleMouseEvent(TerminalMouseEvent event); - - /// Reports resize from layout. [metrics] are in logical pixels and - /// are scaled by [devicePixelRatio] for physical-pixel size reports - /// and Kitty graphics. - void handleResize({ - required int cols, - required int rows, - required CellMetrics metrics, - required EdgeInsets padding, - required double devicePixelRatio, - }); - - /// Reports scroll by line count. - void handleScroll(int lines); - - /// Applies a press selection gesture. - void handleSelectionPress({ - required Position cell, - required Offset localPosition, - required TerminalGestureSettings settings, - }); - - /// Applies a release selection gesture. - void handleSelectionRelease(Position cell); - - /// Reports primary-screen viewport movement that bypasses [Terminal] - /// listeners and may change [Terminal.compressionActivity]. - void handleViewportChanged(); - - /// Invalidates the current selection without publishing a controller change. - void invalidateSelection(); - - /// Requests keyboard focus for the attached view. - void requestFocus(); - - /// Applies one autoscroll tick for an active drag selection. - void updateSelectionAutoscroll({ - required Position cell, - required Offset localPosition, - required bool rectangle, - }); - - /// Updates an active drag selection. - void updateSelectionDrag({ - required Position cell, - required Offset localPosition, - required bool rectangle, - }); - - /// Reports renderer geometry used to anchor platform IME UI. - void updateTextInputGeometry({ - required Size editableSize, - required Matrix4 transform, - required Rect caretRect, - required Rect composingRect, - }); -} diff --git a/packages/flterm/test/widgets/terminal_controller_test.dart b/packages/flterm/test/controller/terminal_controller_test.dart similarity index 55% rename from packages/flterm/test/widgets/terminal_controller_test.dart rename to packages/flterm/test/controller/terminal_controller_test.dart index 41076102..3af3a2cb 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/controller/terminal_controller_test.dart @@ -3,13 +3,10 @@ library; import 'dart:convert'; -import 'package:fake_async/fake_async.dart'; +import 'package:flterm/src/controller/terminal_controller.dart'; import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/widgets/terminal_controller_impl.dart'; -import 'package:flterm/src/widgets/terminal_view_binding.dart'; +import 'package:flterm/src/input/terminal_input_event.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart' - show FocusNode, ScrollController, ScrollPosition; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide KeyEvent; @@ -34,19 +31,583 @@ void main() { controller.write(Uint8List.fromList(utf8.encode(text))); } + TerminalControllerImpl session(TerminalControllerImpl controller) { + return controller; + } + void writeTerminalUtf8(Terminal terminal, String text) { terminal.write(Uint8List.fromList(utf8.encode(text))); } + void enableMouseTracking( + TerminalControllerImpl target, { + String sequence = '\x1b[?1002h\x1b[?1006h', + double devicePixelRatio = 1.0, + }) { + writeControllerUtf8(target, sequence); + session(target).handleResize( + TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: devicePixelRatio, + ), + ); + } + group('constructor', () { - test('returns a TerminalViewBinding', () { - expect(controller, isA()); + test('exposes terminal state without a view attachment', () { + expect(session(controller).terminal, isA()); }); - test('starts without selection, selected text, or focus', () { + test('starts without a selection', () { expect(controller.hasSelection, isFalse); expect(controller.selectedText(), ''); - expect(controller.hasFocus, isFalse); + }); + }); + + group('geometry', () { + test('does not notify a resize callback before view geometry exists', () { + final sizes = <({int cols, int rows})>[]; + + controller.onResize = (cols, rows) { + sizes.add((cols: cols, rows: rows)); + }; + + expect(sizes, isEmpty); + }); + + test('reports the first measured grid to the backend', () { + final sizes = <({int cols, int rows})>[]; + controller.onResize = (cols, rows) { + sizes.add((cols: cols, rows: rows)); + }; + + session(controller).handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(sizes, [(cols: 80, rows: 24)]); + }); + + test( + 'reports committed grid when a callback is assigned after measurement', + () { + session(controller).handleResize( + const TerminalResizeEvent( + cols: 100, + rows: 40, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + final sizes = <({int cols, int rows})>[]; + controller.onResize = (cols, rows) { + sizes.add((cols: cols, rows: rows)); + }; + + expect(sizes, [(cols: 100, rows: 40)]); + }, + ); + + test('allows output from a resize callback after geometry commits', () { + final binding = session(controller); + final output = []; + controller.onOutput = output.add; + controller.onResize = (_, _) { + controller.sendText('ready'); + }; + + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(utf8.decode(output.single), 'ready'); + }); + + test('answers size queries without consuming render dirtiness', () { + final renderState = RenderState(); + addTearDown(renderState.dispose); + + controller.write(Uint8List.fromList(utf8.encode('hello'))); + controller.write(Uint8List.fromList(utf8.encode('\x1b[18t'))); + + renderState.update(session(controller).terminal); + + expect(renderState.dirty, isNot(DirtyState.clean)); + }); + + test('reports configured dimensions before the first view layout', () { + final custom = TerminalControllerImpl( + config: const TerminalConfig(cols: 120, rows: 40), + ); + addTearDown(custom.dispose); + final output = []; + custom.onOutput = output.add; + + custom.write(Uint8List.fromList(utf8.encode('\x1b[18t'))); + + expect(utf8.decode(output.single), '\x1b[8;40;120t'); + }); + + test('applies physical geometry through the resize event', () { + session(controller).handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 2, + ), + ); + + expect(session(controller).terminal.geometry, ( + cols: 80, + rows: 24, + widthPx: 1280, + heightPx: 768, + )); + }); + + test('updates physical geometry when the grid is unchanged', () { + final binding = session(controller); + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 10, + cellHeight: 20, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(binding.terminal.geometry, ( + cols: 80, + rows: 24, + widthPx: 800, + heightPx: 480, + )); + }); + + test('ignores resize events with invalid physical geometry', () { + final binding = session(controller); + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + binding.handleResize( + const TerminalResizeEvent( + cols: 100, + rows: 30, + cellWidth: 0, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(binding.terminal.geometry, ( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + )); + }); + + test('ignores resize events beyond the native grid limit', () { + final binding = session(controller); + + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + binding.handleResize( + const TerminalResizeEvent( + cols: 65536, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(binding.terminal.geometry, ( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + )); + }); + + test('emits the measured in-band resize report', () { + final binding = session(controller); + final output = []; + controller.onOutput = output.add; + binding.terminal.modeSet( + const TerminalMode.inBandResize(), + value: true, + ); + + binding.handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(utf8.decode(output.single), '\x1B[48;24;80;384;640t'); + }); + + test('emits terminal resize output before the backend callback', () { + final binding = session(controller); + final events = []; + controller.onResize = (_, _) => events.add('resize'); + events.clear(); + controller.onOutput = (_) => events.add('output'); + binding.terminal.modeSet( + const TerminalMode.inBandResize(), + value: true, + ); + + binding.handleResize( + const TerminalResizeEvent( + cols: 81, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(events, ['output', 'resize']); + }); + + test('allows backend output during an in-band resize report', () { + final binding = session(controller); + var replied = false; + controller.onOutput = (_) { + replied = true; + binding.write(Uint8List.fromList(utf8.encode('nested'))); + }; + binding.terminal.modeSet( + const TerminalMode.inBandResize(), + value: true, + ); + + binding.handleResize( + const TerminalResizeEvent( + cols: 81, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(replied, isTrue); + }); + }); + + group('handleMouseEvent', () { + test('clears a previous button for buttonless motion', () { + enableMouseTracking(controller, sequence: '\x1b[?1003h\x1b[?1006h'); + final output = []; + controller.onOutput = output.add; + + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .press, + anyButtonPressed: true, + button: .right, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .motion, + anyButtonPressed: false, + button: null, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + + expect(output, hasLength(2)); + expect(utf8.decode(output.last), startsWith('\x1b[<35;')); + }); + + test('passes aggregate pressed state to the encoder', () { + enableMouseTracking(controller); + final output = []; + controller.onOutput = output.add; + + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .motion, + anyButtonPressed: false, + button: .left, + mods: Mods.none(), + pixelX: 1000, + pixelY: 1000, + ), + ); + + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .motion, + anyButtonPressed: true, + button: .left, + mods: Mods.none(), + pixelX: 1000, + pixelY: 1000, + ), + ); + + expect(output, hasLength(1)); + }); + + test('applies device pixel ratio once to SGR pixel coordinates', () { + enableMouseTracking( + controller, + sequence: '\x1b[?1000h\x1b[?1016h', + devicePixelRatio: 2, + ); + final output = []; + controller.onOutput = output.add; + + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .press, + anyButtonPressed: true, + button: .left, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + + expect(utf8.decode(output.single), '\x1b[<0;8;16M'); + }); + + test( + 'maps terminal-local pointer coordinates through surface padding', + () { + enableMouseTracking(controller, sequence: '\x1b[?1000h\x1b[?1016h'); + session(controller).handleResize( + const TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 8, + paddingRight: 4, + paddingTop: 6, + paddingBottom: 2, + devicePixelRatio: 1, + ), + ); + final output = []; + controller.onOutput = output.add; + + session(controller).handleMouseEvent( + const TerminalMouseEvent( + action: .press, + anyButtonPressed: true, + button: .left, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + + expect(utf8.decode(output.single), '\x1b[<0;4;8M'); + }, + ); + }); + + group('handleTerminalScroll', () { + test('uses the last pointer position for tracked scroll', () { + enableMouseTracking(controller); + session( + controller, + ).terminal.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + final output = []; + controller.onOutput = output.add; + + session(controller).handleTerminalScroll( + const TerminalScrollEvent( + horizontal: 0, + mods: Mods.none(), + pixelX: 24, + pixelY: 16, + reportMouse: true, + vertical: -1, + ), + ); + + expect(utf8.decode(output.single), '\x1b[<64;4;2M'); + }); + + test('batches repeated tracked scroll reports', () { + enableMouseTracking(controller); + final output = []; + controller.onOutput = output.add; + + session(controller).handleTerminalScroll( + const TerminalScrollEvent( + horizontal: 0, + mods: Mods.none(), + pixelX: 24, + pixelY: 16, + reportMouse: true, + vertical: -3, + ), + ); + + expect(output, hasLength(1)); + }); + + test( + 'does not simulate cursor keys when alternate scroll is disabled', + () { + session(controller).terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h\x1b[?1007l')), + ); + final output = []; + controller.onOutput = output.add; + + session(controller).handleTerminalScroll( + const TerminalScrollEvent( + horizontal: 0, + mods: Mods.none(), + pixelX: 24, + pixelY: 16, + reportMouse: false, + vertical: -1, + ), + ); + + expect(output, isEmpty); + }, + ); + + test('does not simulate cursor keys while mouse tracking is active', () { + session( + controller, + ).terminal.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + enableMouseTracking(controller); + final output = []; + controller.onOutput = output.add; + + session(controller).handleTerminalScroll( + const TerminalScrollEvent( + horizontal: 0, + mods: Mods.none(), + pixelX: 24, + pixelY: 16, + reportMouse: false, + vertical: -1, + ), + ); + + expect(output, isEmpty); }); }); @@ -291,8 +852,8 @@ void main() { int scrollBack(TerminalControllerImpl target) { writeNumberedLines(target); - target.terminal.scrollViewport(-5); - return target.terminal.scrollbar.offset; + session(target).terminal.scrollViewport(-5); + return session(target).terminal.scrollbar.offset; } test('scrolls to bottom on output when output follow is enabled', () { @@ -302,7 +863,10 @@ void main() { writeControllerUtf8(custom, 'tail\r\n'); - expect(custom.terminal.scrollbar.offset, custom.scrollbackRows); + expect( + session(custom).terminal.scrollbar.offset, + custom.scrollbackRows, + ); }); test( @@ -317,7 +881,7 @@ void main() { end: const Position(row: 0, col: 4), ); - expect(custom.terminal.scrollbar.offset, offset); + expect(session(custom).terminal.scrollbar.offset, offset); }, ); @@ -330,171 +894,46 @@ void main() { start: const Position(row: 0, col: 0), end: const Position(row: 0, col: 4), ); - custom.terminal.scrollViewport(-5); - final offset = custom.terminal.scrollbar.offset; + session(custom).terminal.scrollViewport(-5); + final offset = session(custom).terminal.scrollbar.offset; expect(offset, lessThan(custom.scrollbackRows)); custom.clearSelection(); - expect(custom.terminal.scrollbar.offset, offset); + expect(session(custom).terminal.scrollbar.offset, offset); }, ); - }); - group('scrollback compression', () { - _CompressionScrollController replaceControllerWithCompressionQueue( - _CompressionIdleQueue idle, { - bool viewportAttached = true, - }) { - controller.dispose(); - controller = TerminalControllerImpl( - scheduleCompressionIdle: idle.schedule, - ); - final focusNode = FocusNode(); - final scrollController = _CompressionScrollController( - isAttached: viewportAttached, - ); - addTearDown(focusNode.dispose); - addTearDown(scrollController.dispose); - controller.attach(focusNode, scrollController, viewId: 0); - return scrollController; - } + test('preserves viewport when terminal geometry changes', () { + final custom = outputFollowController(); + final offset = scrollBack(custom); + expect(offset, lessThan(custom.scrollbackRows)); - void createScrollback() { - controller.write( - Uint8List.fromList( - List.filled( - 4000, - 'compressible terminal history\r\n', - ).join().codeUnits, + session(custom).handleResize( + const TerminalResizeEvent( + cols: 20, + rows: 3, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, ), ); - } - - test('schedules compression after terminal activity', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - - createScrollback(); - async.elapse(const Duration(milliseconds: 250)); - expect(idle.length, 1); - }); + expect(session(custom).terminal.scrollbar.offset, offset); }); - test('postpones compression throughout active-screen writes', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - createScrollback(); - async.elapse(const Duration(milliseconds: 200)); - - controller.write(Uint8List.fromList('frame one'.codeUnits)); - async.elapse(const Duration(milliseconds: 200)); - controller.write(Uint8List.fromList('frame two'.codeUnits)); - async.elapse(const Duration(milliseconds: 249)); - - expect(idle.length, 0); - }); - }); - - test('postpones compression after scrolling to the top', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - createScrollback(); - async.elapse(const Duration(milliseconds: 200)); - - controller.scrollToTop(); - async.elapse(const Duration(milliseconds: 50)); - - expect(idle.length, 0); - }); - }); - - test('postpones compression after scrolling to the bottom', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - createScrollback(); - controller.scrollToTop(); - async.elapse(const Duration(milliseconds: 200)); - - controller.scrollToBottom(); - async.elapse(const Duration(milliseconds: 50)); - - expect(idle.length, 0); - }); - }); - - test('cancels pending compression when detached', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - createScrollback(); - - controller.detach(); - async.elapse(const Duration(milliseconds: 250)); - - expect(idle.length, 0); - }); - }); - - test('ignores terminal activity while detached', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - controller.detach(); - - createScrollback(); - async.elapse(const Duration(milliseconds: 250)); - - expect(idle.length, 0); - }); - }); - - testWidgets('waits for the viewport to attach', (tester) async { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle, viewportAttached: false); - - await tester.pump(const Duration(milliseconds: 250)); - - expect(idle.length, 0); - }); - - testWidgets('schedules compression after the viewport attaches', ( - tester, - ) async { - final idle = _CompressionIdleQueue(); - final scrollController = replaceControllerWithCompressionQueue( - idle, - viewportAttached: false, - ); - - scrollController.isAttached = true; - createScrollback(); - await tester.pump(const Duration(milliseconds: 250)); - - expect(idle.length, 1); - }); - - test('schedules compression when reattached', () { - fakeAsync((async) { - final idle = _CompressionIdleQueue(); - replaceControllerWithCompressionQueue(idle); - createScrollback(); - controller.detach(); - final focusNode = FocusNode(); - final scrollController = _CompressionScrollController(); - addTearDown(focusNode.dispose); - addTearDown(scrollController.dispose); + test('preserves viewport when a terminal mode changes', () { + final custom = outputFollowController(); + final offset = scrollBack(custom); + expect(offset, lessThan(custom.scrollbackRows)); - controller.attach(focusNode, scrollController, viewId: 0); - async.elapse(const Duration(milliseconds: 250)); + custom.modeSet(const .bracketedPaste(), value: true); - expect(idle.length, 1); - }); + expect(session(custom).terminal.scrollbar.offset, offset); }); }); @@ -750,10 +1189,9 @@ void main() { }); test('wraps with bracketed paste escape when mode is active', () { - controller.terminal.modeSet( - const TerminalMode.bracketedPaste(), - value: true, - ); + session( + controller, + ).terminal.modeSet(const TerminalMode.bracketedPaste(), value: true); final output = []; controller.onOutput = output.add; @@ -811,15 +1249,15 @@ void main() { addTearDown(custom.dispose); addTearDown(renderState.dispose); - expect(custom.terminal.scrollbackMaxBytes, 1024); - expect(custom.terminal.scrollbackMaxLines, 10); + expect(session(custom).terminal.scrollbackMaxBytes, 1024); + expect(session(custom).terminal.scrollbackMaxLines, 10); custom.write(transmitRedPixel(id: 91)); - expect(KittyGraphics.of(custom.terminal)!.image(91), isNull); + expect(KittyGraphics.of(session(custom).terminal)!.image(91), isNull); - writeTerminalUtf8(custom.terminal, '\x1b[0 q'); - renderState.update(custom.terminal); + writeTerminalUtf8(session(custom).terminal, '\x1b[0 q'); + renderState.update(session(custom).terminal); expect(renderState.cursor.shape, CursorShape.underline); expect(renderState.cursor.blinking, isTrue); @@ -831,8 +1269,8 @@ void main() { scrollbackMaxLines: 20, ); - expect(controller.terminal.scrollbackMaxBytes, 2048); - expect(controller.terminal.scrollbackMaxLines, 20); + expect(session(controller).terminal.scrollbackMaxBytes, 2048); + expect(session(controller).terminal.scrollbackMaxLines, 20); }); test('setter applies APC buffer limits', () { @@ -840,7 +1278,10 @@ void main() { controller.write(transmitRedPixel(id: 92)); - expect(KittyGraphics.of(controller.terminal)!.image(92), isNull); + expect( + KittyGraphics.of(session(controller).terminal)!.image(92), + isNull, + ); }); test('setter applies cursor reset defaults', () { @@ -851,8 +1292,8 @@ void main() { cursorStyle: CursorShape.bar, cursorBlink: false, ); - writeTerminalUtf8(controller.terminal, '\x1b[0 q'); - renderState.update(controller.terminal); + writeTerminalUtf8(session(controller).terminal, '\x1b[0 q'); + renderState.update(session(controller).terminal); expect(renderState.cursor.shape, CursorShape.bar); expect(renderState.cursor.blinking, isFalse); @@ -875,7 +1316,7 @@ void main() { }); test('switches to alternate via escape sequence', () { - writeTerminalUtf8(controller.terminal, '\x1b[?1049h'); + writeTerminalUtf8(session(controller).terminal, '\x1b[?1049h'); expect(controller.activeScreen, TerminalScreen.alternate); }); }); @@ -886,7 +1327,10 @@ void main() { }); test('updates via OSC 0 escape sequence', () { - writeTerminalUtf8(controller.terminal, '\x1b]0;my title\x1b\\'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]0;my title\x1b\\', + ); expect(controller.title, 'my title'); }); @@ -894,7 +1338,10 @@ void main() { var fired = false; controller.onTitleChanged = () => fired = true; - writeTerminalUtf8(controller.terminal, '\x1b]0;new title\x1b\\'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]0;new title\x1b\\', + ); expect(fired, isTrue); }); @@ -902,7 +1349,10 @@ void main() { group('pwd', () { test('updates via OSC 7 escape sequence', () { - writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); expect(controller.pwd, 'file:///tmp'); }); @@ -911,16 +1361,49 @@ void main() { var notifyCount = 0; controller.addListener(() => notifyCount++); - writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); expect(notifyCount, greaterThan(0)); }); + test('notifies listeners once when a PWD callback is set', () { + var notifyCount = 0; + controller.onPwdChanged = () {}; + controller.addListener(() => notifyCount++); + + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); + + expect(notifyCount, 1); + }); + + test('keeps observing PWD changes after callback is cleared', () { + var notifyCount = 0; + controller.onPwdChanged = () {}; + controller.onPwdChanged = null; + controller.addListener(() => notifyCount++); + + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); + + expect(notifyCount, 1); + }); + test('fires onPwdChanged callback', () { var fired = false; controller.onPwdChanged = () => fired = true; - writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); expect(fired, isTrue); }); @@ -929,7 +1412,10 @@ void main() { var pwd = ''; controller.onPwdChanged = () => pwd = controller.pwd; - writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); + writeTerminalUtf8( + session(controller).terminal, + '\x1b]7;file:///tmp\x07', + ); expect(pwd, 'file:///tmp'); }); @@ -941,6 +1427,21 @@ void main() { expect(disposable.dispose, returnsNormally); }); + + test('allows repeated disposal', () { + controller.dispose(); + + expect(controller.dispose, returnsNormally); + }); + + test('rejects writes after disposal', () { + controller.dispose(); + + expect( + () => controller.write(Uint8List.fromList([0x61])), + throwsA(isA()), + ); + }); }); group('virtual mods', () { @@ -1035,35 +1536,3 @@ void main() { }); }); } - -final class _CompressionIdleQueue { - final List _callbacks = []; - - int get length => _callbacks.length; - - void schedule(VoidCallback callback) => _callbacks.add(callback); -} - -final class _CompressionScrollController extends ScrollController { - final ScrollPosition _position = _CompressionScrollPosition(); - bool isAttached; - - _CompressionScrollController({this.isAttached = true}); - - @override - bool get hasClients => isAttached; - - @override - ScrollPosition get position => _position; - - @override - void jumpTo(double value) {} -} - -final class _CompressionScrollPosition implements ScrollPosition { - @override - double get maxScrollExtent => 0; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/packages/flterm/test/foundation/terminal_geometry_test.dart b/packages/flterm/test/foundation/terminal_geometry_test.dart new file mode 100644 index 00000000..f7177af9 --- /dev/null +++ b/packages/flterm/test/foundation/terminal_geometry_test.dart @@ -0,0 +1,50 @@ +import 'package:flterm/src/foundation/terminal_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TerminalGeometry', () { + TerminalResizeEvent resizeEvent({ + int cols = 80, + int rows = 24, + double cellWidth = 8, + }) => TerminalResizeEvent( + cols: cols, + rows: rows, + cellWidth: cellWidth, + cellHeight: 16, + paddingLeft: 4, + paddingRight: 4, + paddingTop: 2, + paddingBottom: 2, + devicePixelRatio: 2, + ); + + test('normalizes a valid resize event', () { + final geometry = TerminalGeometry.tryFrom(resizeEvent()); + + expect(geometry, isNotNull); + expect(geometry!.cellWidthPx, 16); + expect(geometry.screenWidth, 1296); + expect(geometry.screenHeight, 776); + }); + + test('compares equivalent normalized measurements as equal', () { + final first = TerminalGeometry.tryFrom(resizeEvent()); + final second = TerminalGeometry.tryFrom(resizeEvent()); + + expect(first, second); + }); + + test('rejects a measurement with an invalid cell width', () { + final geometry = TerminalGeometry.tryFrom(resizeEvent(cellWidth: 0)); + + expect(geometry, isNull); + }); + + test('rejects a measurement beyond the native grid limit', () { + final geometry = TerminalGeometry.tryFrom(resizeEvent(cols: 0x10000)); + + expect(geometry, isNull); + }); + }); +} diff --git a/packages/flterm/test/input/terminal_gesture_detector_test.dart b/packages/flterm/test/input/terminal_gesture_detector_test.dart new file mode 100644 index 00000000..cb57926e --- /dev/null +++ b/packages/flterm/test/input/terminal_gesture_detector_test.dart @@ -0,0 +1,3737 @@ +@Tags(['ffi']) +library; + +import 'dart:convert'; + +import 'package:flterm/src/controller/terminal_controller.dart'; +import 'package:flterm/src/foundation.dart'; +import 'package:flterm/src/input/terminal_gesture_detector.dart'; +import 'package:flterm/src/links/link_interaction.dart'; +import 'package:flterm/src/links/link_settings.dart'; +import 'package:flterm/src/view/terminal_view_attachment.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' + show + Mods, + MouseTracking, + Position, + Selection, + SelectionGestureBehaviors, + Terminal; + +extension _SelectionEdges on Selection { + Position get _startPoint => start.positionIn(.viewport)!; + + Position get _endPoint => end.positionIn(.viewport)!; + + bool get _forward { + final start = _startPoint; + final end = _endPoint; + return start.row != end.row ? start.row < end.row : start.col <= end.col; + } + + int get startRow => _startPoint.row; + + int get startCol => _forward ? _startPoint.col : _startPoint.col + 1; + + int get endRow => _endPoint.row; + + int get endCol => _forward ? _endPoint.col + 1 : _endPoint.col; + + TerminalSelectionShape get mode { + return rectangle + ? TerminalSelectionShape.rectangle + : TerminalSelectionShape.normal; + } +} + +void main() { + group('TerminalGestureDetector', () { + const defaultMetrics = CellMetrics( + cellWidth: 8, + cellHeight: 16, + baseline: 12, + ); + final enableNormalMouse = Uint8List.fromList(utf8.encode('\x1b[?1000h')); + final enableX10Mouse = Uint8List.fromList(utf8.encode('\x1b[?9h')); + final enableSgrMouse = Uint8List.fromList( + utf8.encode('\x1b[?1000h\x1b[?1006h'), + ); + final enableButtonSgrMouse = Uint8List.fromList( + utf8.encode('\x1b[?1002h\x1b[?1006h'), + ); + final enableAnySgrMouse = Uint8List.fromList( + utf8.encode('\x1b[?1003h\x1b[?1006h'), + ); + + final adapters = {}; + + TerminalViewAttachment bindingFor(TerminalController controller) { + return adapters.putIfAbsent(controller, () { + final adapter = TerminalViewAttachment(controller); + addTearDown(adapter.dispose); + return adapter; + }); + } + + Terminal terminalFor(TerminalController controller) { + return bindingFor(controller).terminal; + } + + void writeToTerminal(TerminalController controller, String text) { + terminalFor(controller).write(Uint8List.fromList(utf8.encode(text))); + } + + void commitGeometry( + TerminalController controller, { + int cols = 80, + int rows = 24, + }) { + bindingFor(controller).handleResize( + TerminalResizeEvent( + cols: cols, + rows: rows, + cellWidth: defaultMetrics.cellWidth, + cellHeight: defaultMetrics.cellHeight, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + } + + Widget buildHandler({ + required TerminalController controller, + TerminalViewAttachment? attachment, + CellMetrics metrics = defaultMetrics, + TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), + LinkInteraction? links, + ValueChanged? onLinkActivate, + ScrollController? scrollController, + ScrollPhysics scrollPhysics = const ClampingScrollPhysics(), + }) { + final resolvedAttachment = attachment ?? bindingFor(controller); + return Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: TerminalGestureDetector( + attachment: resolvedAttachment, + metrics: metrics, + interaction: resolvedAttachment.interaction.value, + links: links ?? LinkInteraction(), + onLinkActivate: onLinkActivate, + settings: gestureSettings, + scrollController: scrollController, + scrollPhysics: scrollPhysics, + child: const SizedBox(width: 640, height: 384), + ), + ), + ); + } + + LinkInteraction linkInteractionFor(TerminalController controller) { + final links = LinkInteraction(); + links.update( + context: LinkContext( + terminal: terminalFor(controller), + rows: 24, + cols: 80, + cwd: null, + ), + settings: LinkSettings(modifier: .none, onActivate: (_) {}), + idleStyle: const HyperlinkStyle(), + ); + return links; + } + + void enableMouseTracking( + TerminalController controller, { + MouseTracking mode = .normal, + }) { + final seq = switch (mode) { + .normal => enableNormalMouse, + .x10 => enableX10Mouse, + _ => enableNormalMouse, + }; + final viewBinding = bindingFor(controller); + viewBinding.terminal.write(seq); + commitGeometry(controller); + } + + void enableSgrMouseTracking(TerminalController controller) { + final viewBinding = bindingFor(controller); + viewBinding.terminal.write(enableSgrMouse); + commitGeometry(controller); + } + + void enableAnySgrMouseTracking(TerminalController controller) { + final viewBinding = bindingFor(controller); + viewBinding.terminal.write(enableAnySgrMouse); + commitGeometry(controller); + } + + void enableButtonSgrMouseTracking(TerminalController controller) { + final viewBinding = bindingFor(controller); + viewBinding.terminal.write(enableButtonSgrMouse); + commitGeometry(controller); + } + + String decodeEvents(List events) { + return utf8.decode( + Uint8List.fromList(events.expand((event) => event).toList()), + ); + } + + List sgrCodes(List events) { + return RegExp('\x1b\\[<(\\d+);').allMatches(decodeEvents(events)).map(( + match, + ) { + return int.parse(match.group(1)!); + }).toList(); + } + + List<({int x, int y})> sgrPositions(List events) { + return RegExp( + '\x1b\\[<\\d+;(\\d+);(\\d+)[Mm]', + ).allMatches(decodeEvents(events)).map((match) { + return (x: int.parse(match.group(1)!), y: int.parse(match.group(2)!)); + }).toList(); + } + + Future sendPointerEvent( + WidgetTester tester, + PointerEvent event, + ) async { + await tester.sendEventToBinding(event); + await tester.pump(); + } + + Future mouseDown( + WidgetTester tester, + Offset pos, { + int buttons = kPrimaryButton, + int? pointer, + }) { + return tester.startGesture( + pos, + kind: .mouse, + buttons: buttons, + pointer: pointer, + ); + } + + late TerminalController controller; + + setUp(() => controller = TerminalController()); + + tearDown(() => controller.dispose()); + + Future tapMouse( + WidgetTester tester, + Offset position, { + int count = 1, + }) async { + for (var i = 0; i < count; i++) { + final gesture = await mouseDown(tester, position); + await gesture.up(); + } + } + + testWidgets('tap leaves selection empty', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(40, 16)); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('tap activates a link without starting selection', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); + + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); + + await tapMouse(tester, const Offset(8, 0)); + + expect(links, hasLength(1)); + expect(links.single.text, 'https://example.test'); + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('tap up activates the press candidate after invalidation', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); + + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + linkInteraction.invalidateContent(); + await gesture.up(); + + expect(links.single.text, 'https://example.test'); + }); + + testWidgets('replacing link interaction cancels the outgoing press', ( + tester, + ) async { + writeToTerminal(controller, 'https://example.test'); + final outgoing = linkInteractionFor(controller); + final incoming = linkInteractionFor(controller); + + await tester.pumpWidget( + buildHandler(controller: controller, links: outgoing), + ); + final gesture = await mouseDown(tester, const Offset(8, 0)); + await tester.pumpWidget( + buildHandler(controller: controller, links: incoming), + ); + + final staleLink = outgoing.handleRelease( + localPosition: const Offset(8, 0), + metrics: defaultMetrics, + ); + await gesture.up(); + + expect(staleLink, isNull); + }); + + testWidgets('drag cancels claimed link tap', (tester) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); + + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await tester.pump(kPressTimeout); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + expect(links, isEmpty); + }); + + testWidgets('touch scroll cancels a claimed link tap', (tester) async { + writeToTerminal(controller, '\x1b[?1049hhttps://example.test'); + final linkInteraction = linkInteractionFor(controller); + + await tester.pumpWidget( + buildHandler(controller: controller, links: linkInteraction), + ); + final gesture = await tester.startGesture(const Offset(8, 8)); + await tester.pump(kPressTimeout); + await gesture.moveBy(const Offset(0, 64)); + await gesture.up(); + final released = linkInteraction.handleRelease( + localPosition: const Offset(8, 8), + metrics: defaultMetrics, + ); + + expect(released, isNull); + }); + + testWidgets('mouse tracking takes priority over link activation', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); + enableMouseTracking(controller); + + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); + + await tapMouse(tester, const Offset(8, 0)); + + expect(links, isEmpty); + }); + + testWidgets('drag creates selection with correct cells', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 1); + expect(selection.endRow, 1); + expect(selection.endCol, 5); + expect(selection.mode, TerminalSelectionShape.normal); + }); + + testWidgets('stylus drag creates selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await tester.startGesture( + const Offset(8, 0), + kind: .stylus, + pointer: 83, + ); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNotNull); + }); + + testWidgets('inverted stylus drag creates selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await tester.startGesture( + const Offset(8, 0), + kind: .invertedStylus, + pointer: 84, + ); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNotNull); + }); + + testWidgets('mouse up ends selection drag', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.endRow, 2); + }); + + testWidgets('drag to same cell does not change selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(40, 16)); + final selAfterFirst = terminalFor(controller).selection; + + await gesture.moveTo(const Offset(41, 17)); + final selAfterSecond = terminalFor(controller).selection; + + expect(selAfterFirst, selAfterSecond); + + await gesture.up(); + }); + + testWidgets('selection autoscroll follows the committed grid', ( + tester, + ) async { + final target = TerminalController( + config: const TerminalConfig(cols: 10, rows: 2), + ); + addTearDown(target.dispose); + final attachment = bindingFor(target); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + commitGeometry(target, cols: 10, rows: 2); + writeToTerminal(target, '0\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9'); + target.scrollToTop(); + await tester.pumpWidget( + Directionality( + textDirection: .ltr, + child: Scrollable( + controller: scrollController, + viewportBuilder: (_, _) => buildHandler( + controller: target, + attachment: attachment, + scrollController: scrollController, + ), + ), + ), + ); + final pointer = await mouseDown(tester, const Offset(8, 8)); + + await pointer.moveTo(const Offset(8, 64)); + await tester.pump(); + final rowAfterMove = attachment.terminal.scrollbar.offset; + await tester.pump(const Duration(milliseconds: 120)); + final viewportRow = attachment.terminal.scrollbar.offset; + await pointer.up(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(viewportRow, greaterThan(rowAfterMove)); + }); + + testWidgets('double click selects word', (tester) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(8, 0), count: 2); + + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 0); + expect(selection.endCol, 5); + }); + + testWidgets('distant pointer timestamps do not form a double click', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + await tester.pumpWidget(buildHandler(controller: controller)); + + const position = Offset(8, 0); + final firstPointer = TestPointer(81, PointerDeviceKind.mouse); + await sendPointerEvent(tester, firstPointer.down(position)); + await sendPointerEvent( + tester, + firstPointer.up(timeStamp: const Duration(milliseconds: 10)), + ); + final secondPointer = TestPointer(82, PointerDeviceKind.mouse); + await sendPointerEvent( + tester, + secondPointer.down(position, timeStamp: const Duration(seconds: 1)), + ); + await sendPointerEvent( + tester, + secondPointer.up(timeStamp: const Duration(milliseconds: 1010)), + ); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('double click on second word selects it', (tester) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(56, 0), count: 2); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 6); + expect(selection.endCol, 11); + }); + + testWidgets('double click uses configured word boundaries', (tester) async { + final boundaryController = TerminalController(); + addTearDown(boundaryController.dispose); + writeToTerminal(boundaryController, 'hello_world'); + + await tester.pumpWidget( + buildHandler( + controller: boundaryController, + gestureSettings: const TerminalGestureSettings(wordBoundaries: '_'), + ), + ); + + await tapMouse(tester, const Offset(64, 0), count: 2); + + final selection = terminalFor(boundaryController).selection!; + expect(selection.startCol, 6); + expect(selection.endCol, 11); + }); + + testWidgets('triple click selects line content only', (tester) async { + writeToTerminal(controller, 'Hello'); + + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(40, 0), count: 3); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 5); + }); + + testWidgets('triple click on wrapped line selects full terminal line', ( + tester, + ) async { + final narrowController = TerminalController( + config: const TerminalConfig(cols: 10, rows: 5), + ); + addTearDown(narrowController.dispose); + + writeToTerminal(narrowController, 'ABCDEFGHIJKLMNO'); + + await tester.pumpWidget(buildHandler(controller: narrowController)); + + await tapMouse(tester, const Offset(8, 16), count: 3); + + final selection = terminalFor(narrowController).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 0); + expect(selection.endRow, 1); + expect(selection.endCol, 5); + }); + + testWidgets('triple click with fullRow mode selects entire row width', ( + tester, + ) async { + final wideController = TerminalController( + config: const TerminalConfig(cols: 20, rows: 5), + ); + addTearDown(wideController.dispose); + + writeToTerminal(wideController, 'Hello'); + + await tester.pumpWidget( + buildHandler( + controller: wideController, + gestureSettings: const TerminalGestureSettings(lineSelectMode: .full), + ), + ); + + await tapMouse(tester, const Offset(8, 0), count: 3); + + final selection = terminalFor(wideController).selection!; + expect(selection.endCol, 20); + }); + + testWidgets('tap counting resets on distant clicks', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(40, 16)); + await tapMouse(tester, const Offset(200, 200)); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('touch long press starts normal selection by default', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await tester.startGesture(const Offset(40, 16)); + + await tester.pump(const Duration(milliseconds: 550)); + + expect(terminalFor(controller).selection, isNull); + + await gesture.moveTo(const Offset(80, 32)); + final sel = terminalFor(controller).selection!; + expect(sel.mode, TerminalSelectionShape.normal); + + await gesture.up(); + }); + + testWidgets('touch move cancels long press if distance exceeds threshold', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await tester.startGesture(const Offset(40, 16)); + await gesture.moveTo(const Offset(80, 16)); + + await tester.pump(const Duration(milliseconds: 550)); + + await gesture.moveTo(const Offset(120, 16)); + expect(terminalFor(controller).selection, isNull); + + await gesture.up(); + }); + + testWidgets('new click clears existing selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNotNull); + + final gesture2 = await mouseDown(tester, const Offset(40, 16)); + await gesture2.up(); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('click without existing selection keeps selection null', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(40, 16)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNull); + }); + + group('gesture settings', () { + testWidgets('dragSelection false prevents drag selection', ( + tester, + ) async { + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + dragSelection: false, + ), + ), + ); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('longPressSelection false cancels press selection', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + longPressSelection: false, + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .line, + doubleClick: .word, + tripleClick: .line, + ), + ), + ), + ); + + final gesture = await tester.startGesture(const Offset(40, 16)); + await tester.pump(const Duration(milliseconds: 550)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('single click uses configured line behavior', (tester) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .line, + doubleClick: .word, + tripleClick: .line, + ), + ), + ), + ); + + await tapMouse(tester, const Offset(8, 0)); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 11); + }); + + testWidgets('double click uses configured line behavior', (tester) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .cell, + doubleClick: .line, + tripleClick: .line, + ), + ), + ), + ); + + await tapMouse(tester, const Offset(8, 0), count: 2); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 11); + }); + + testWidgets('triple click uses configured word behavior', (tester) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .cell, + doubleClick: .line, + tripleClick: .word, + ), + ), + ), + ); + + await tapMouse(tester, const Offset(56, 0), count: 3); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 6); + expect(selection.endCol, 11); + }); + + testWidgets('dragSelection false keeps press selection enabled', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + dragSelection: false, + ), + ), + ); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + expect(terminalFor(controller).selection, isNull); + + await tapMouse(tester, const Offset(8, 0), count: 2); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 5); + }); + + testWidgets('double click cell behavior leaves selection empty', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .cell, + doubleClick: .cell, + tripleClick: .line, + ), + ), + ), + ); + + await tapMouse(tester, const Offset(8, 0), count: 2); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('triple click cell behavior leaves selection empty', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + selectionBehaviors: SelectionGestureBehaviors( + singleClick: .cell, + doubleClick: .word, + tripleClick: .cell, + ), + ), + ), + ); + + await tapMouse(tester, const Offset(8, 0), count: 3); + + expect(terminalFor(controller).selection, isNull); + }); + + testWidgets('longPressSelectionShape block uses block mode', ( + tester, + ) async { + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + longPressSelectionShape: .rectangle, + ), + ), + ); + + final gesture = await tester.startGesture(const Offset(40, 16)); + await tester.pump(const Duration(milliseconds: 550)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.mode, TerminalSelectionShape.rectangle); + }); + + testWidgets( + 'disabled selection affordances still allow mouse tracking output', + (tester) async { + enableMouseTracking(controller); + + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + dragSelection: false, + longPressSelection: false, + selectAllShortcut: false, + ), + ), + ); + + final events = []; + controller.onOutput = events.add; + + final gesture = await mouseDown(tester, const Offset(24, 16)); + await gesture.up(); + + expect(events, isNotEmpty); + }, + ); + }); + + group('physical mods', () { + testWidgets('Alt press changes an active selection to rectangular', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await mouseDown(tester, const Offset(8, 0)); + addTearDown(gesture.up); + await gesture.moveTo(const Offset(80, 32)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + addTearDown(HardwareKeyboard.instance.clearState); + + expect( + terminalFor(controller).selection!.mode, + TerminalSelectionShape.rectangle, + ); + }); + + testWidgets('Alt release changes an active selection to normal', ( + tester, + ) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + addTearDown(HardwareKeyboard.instance.clearState); + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await mouseDown(tester, const Offset(8, 0)); + addTearDown(gesture.up); + await gesture.moveTo(const Offset(80, 32)); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + expect( + terminalFor(controller).selection!.mode, + TerminalSelectionShape.normal, + ); + }); + }); + + group('virtual mods', () { + testWidgets('virtual alt triggers block selection on drag', ( + tester, + ) async { + controller.toggleMod(const Mods.alt()); + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.mode, TerminalSelectionShape.rectangle); + }); + + testWidgets('virtual alt triggers block selection on long press', ( + tester, + ) async { + controller.toggleMod(const Mods.alt()); + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await tester.startGesture(const Offset(40, 16)); + await tester.pump(const Duration(milliseconds: 550)); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.mode, TerminalSelectionShape.rectangle); + }); + + testWidgets('toggling alt mid-drag switches selection mode', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(80, 32)); + expect( + terminalFor(controller).selection!.mode, + TerminalSelectionShape.normal, + ); + + controller.toggleMod(const Mods.alt()); + await gesture.moveTo(const Offset(80, 48)); + expect( + terminalFor(controller).selection!.mode, + TerminalSelectionShape.rectangle, + ); + + controller.toggleMod(const Mods.alt()); + await gesture.moveTo(const Offset(80, 64)); + expect( + terminalFor(controller).selection!.mode, + TerminalSelectionShape.normal, + ); + + await gesture.up(); + }); + + testWidgets('virtual shift bypasses mouse tracking', (tester) async { + controller.toggleMod(const Mods.shift()); + enableMouseTracking(controller); + + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 16)); + await gesture.up(); + + expect(events, isEmpty); + }); + + testWidgets('virtual control is encoded in tracked mouse input', ( + tester, + ) async { + controller.toggleMod(const Mods.ctrl()); + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 500, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 500, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [16, 16]); + }); + }); + + group('wide character selection snapping', () { + setUp(() { + terminalFor(controller).write(Uint8List.fromList(utf8.encode('AB日CD'))); + }); + + testWidgets('drag from spacer snaps anchor inclusive', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 0)); + await gesture.moveTo(const Offset(40, 0)); + await gesture.up(); + + expect(controller.selectedText(), '日C'); + }); + + testWidgets('drag ending on wide char snaps end exclusive', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(24, 0)); + expect(controller.selectedText(), 'AB日'); + + await gesture.moveTo(const Offset(16, 0)); + expect(controller.selectedText(), 'AB'); + + await gesture.up(); + }); + + testWidgets('leftward drag from spacer snaps anchor exclusive', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 0)); + await gesture.moveTo(Offset.zero); + await gesture.up(); + + expect(controller.selectedText(), 'AB日'); + }); + + testWidgets('narrow cells pass through unaffected', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(8, 0)); + await gesture.up(); + + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 1); + }); + + testWidgets('double click on spacer leaves selection empty', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); + + await tapMouse(tester, const Offset(24, 0), count: 2); + + expect(terminalFor(controller).selection, isNull); + }); + }); + + group('lifecycle', () { + testWidgets('preserves a settled selection on unmount', (tester) async { + writeToTerminal(controller, 'selected'); + controller.selectAll(); + await tester.pumpWidget(buildHandler(controller: controller)); + + await tester.pumpWidget(const SizedBox()); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('preserves outgoing selection on controller replacement', ( + tester, + ) async { + writeToTerminal(controller, 'selected'); + controller.selectAll(); + final replacement = TerminalController(); + addTearDown(replacement.dispose); + await tester.pumpWidget(buildHandler(controller: controller)); + + await tester.pumpWidget(buildHandler(controller: replacement)); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('preserves incoming selection on controller replacement', ( + tester, + ) async { + final replacement = TerminalController(); + addTearDown(replacement.dispose); + writeToTerminal(replacement, 'selected'); + replacement.selectAll(); + await tester.pumpWidget(buildHandler(controller: controller)); + + await tester.pumpWidget(buildHandler(controller: replacement)); + + expect(replacement.hasSelection, isTrue); + }); + + testWidgets( + 'does not retain the outgoing interaction owner after replacement', + (tester) async { + final replacement = TerminalController(); + addTearDown(replacement.dispose); + writeToTerminal(replacement, 'selected'); + bindingFor(replacement).handleResize( + TerminalResizeEvent( + cols: 80, + rows: 24, + cellWidth: defaultMetrics.cellWidth, + cellHeight: defaultMetrics.cellHeight, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + await tester.pumpWidget(buildHandler(controller: controller)); + + final outgoing = await mouseDown( + tester, + const Offset(8, 8), + pointer: 24, + ); + await tester.pumpWidget(buildHandler(controller: replacement)); + + final incoming = await mouseDown( + tester, + const Offset(8, 8), + pointer: 25, + ); + await incoming.moveTo(const Offset(80, 8)); + expect(replacement.hasSelection, isTrue); + await sendPointerEvent( + tester, + const PointerCancelEvent(pointer: 25, position: Offset(80, 8)), + ); + + expect(replacement.hasSelection, isFalse); + await incoming.removePointer(); + await outgoing.up(); + }, + ); + + testWidgets('releases a forwarded mouse press on unmount', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await mouseDown( + tester, + const Offset(24, 16), + pointer: 1001, + ); + events.clear(); + + await tester.pumpWidget(const SizedBox()); + + expect(decodeEvents(events), '\x1b[<0;4;2m'); + await gesture.up(); + }); + + testWidgets('releases a forwarded press from the outgoing controller', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + final replacement = TerminalController(); + addTearDown(replacement.dispose); + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await mouseDown( + tester, + const Offset(24, 16), + pointer: 1002, + ); + events.clear(); + + await tester.pumpWidget(buildHandler(controller: replacement)); + + expect(decodeEvents(events), '\x1b[<0;4;2m'); + await gesture.up(); + }); + }); + + group('mouse tracking', () { + testWidgets('maps primary mouse button to left', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 1, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 1, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [0, 0]); + }); + + testWidgets('maps middle mouse button to middle', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 2, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kMiddleMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 2, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [1, 1]); + }); + + testWidgets('maps secondary mouse button to right', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 3, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 3, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [2, 2]); + }); + + testWidgets('maps back and forward mouse buttons independently', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 4, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kBackMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 4, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 5, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kForwardMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 5, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [128, 128, 129, 129]); + }); + + testWidgets('retains the pressed button during motion', (tester) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 6, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 6, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 6, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [2, 34, 2]); + }); + + testWidgets('reports mouse button changes within one pointer sequence', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 41, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 41, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton | kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 41, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 41, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 41, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect( + decodeEvents(events), + '\x1b[<0;4;2M\x1b[<2;4;2M\x1b[<0;4;2m' + '\x1b[<34;5;2M\x1b[<2;5;2m', + ); + }); + + testWidgets('keeps terminal ownership when tracking changes', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 40, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + writeToTerminal(controller, '\x1b[?1003l'); + await tester.pump(); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 40, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + ), + ); + writeToTerminal(controller, '\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 40, + position: Offset(40, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 40, + position: Offset(40, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [0, 32, 0]); + }); + + testWidgets('keeps terminal ownership when Shift changes', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await mouseDown( + tester, + const Offset(24, 16), + pointer: 1003, + ); + events.clear(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await gesture.moveBy(const Offset(8, 0)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await gesture.up(); + + expect(sgrCodes(events), [36, 0]); + }); + + testWidgets('keeps simultaneous pointers independent', (tester) async { + enableButtonSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 14, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 15, + position: Offset(32, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 14, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 15, + position: Offset(40, 16), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 15, + position: Offset(40, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [0, 2, 0, 34, 2]); + }); + + testWidgets('forwards aggregate buttons and ignores unknown buttons', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 7, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: kPrimaryMouseButton | kSecondaryMouseButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 7, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 8, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + buttons: 0x20, + ), + ); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 19, + position: Offset(24, 16), + kind: PointerDeviceKind.unknown, + ), + ); + + expect(sgrCodes(events), [0, 2, 0, 2]); + }); + + testWidgets('forwards stylus contact and barrel buttons', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 9, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 9, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + ), + ); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 10, + position: Offset(24, 16), + kind: PointerDeviceKind.invertedStylus, + buttons: kStylusContact | kPrimaryStylusButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 10, + position: Offset(24, 16), + kind: PointerDeviceKind.invertedStylus, + ), + ); + + expect(sgrCodes(events), [0, 0, 2, 2]); + }); + + testWidgets('maps a secondary stylus barrel button to middle', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 18, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + buttons: kSecondaryStylusButton, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 18, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + ), + ); + + expect(sgrCodes(events), [1, 1]); + }); + + testWidgets('reports stylus barrel changes within one pointer sequence', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 42, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 42, + position: Offset(24, 16), + kind: PointerDeviceKind.stylus, + buttons: kStylusContact | kPrimaryStylusButton, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 42, + position: Offset(32, 16), + kind: PointerDeviceKind.stylus, + buttons: kStylusContact | kPrimaryStylusButton, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent( + pointer: 42, + position: Offset(32, 16), + kind: PointerDeviceKind.stylus, + ), + ); + await sendPointerEvent( + tester, + const PointerUpEvent( + pointer: 42, + position: Offset(32, 16), + kind: PointerDeviceKind.stylus, + ), + ); + + expect( + decodeEvents(events), + '\x1b[<0;4;2M\x1b[<0;4;2m\x1b[<2;4;2M' + '\x1b[<34;5;2M\x1b[<2;5;2m\x1b[<0;5;2M\x1b[<0;5;2m', + ); + }); + + testWidgets('cancelling a tracked pointer emits one release', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 11, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerCancelEvent( + pointer: 11, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + await sendPointerEvent( + tester, + const PointerCancelEvent( + pointer: 11, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [0, 0]); + }); + + testWidgets('ignores cancellation without a forwarded press', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerCancelEvent( + pointer: 20, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(events, isEmpty); + }); + + testWidgets( + 'does not cancel an active selection for an unrelated pointer', + (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); + final mouse = await mouseDown( + tester, + const Offset(8, 8), + pointer: 22, + ); + await mouse.moveTo(const Offset(80, 8)); + expect(controller.hasSelection, isTrue); + + await sendPointerEvent( + tester, + const PointerDownEvent(pointer: 23, position: Offset(8, 8)), + ); + await sendPointerEvent( + tester, + const PointerCancelEvent(pointer: 23, position: Offset(8, 8)), + ); + + expect(controller.hasSelection, isTrue); + await mouse.up(); + }, + ); + + testWidgets('does not synthesize a click when touch is cancelled', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent(pointer: 21, position: Offset(24, 16)), + ); + await sendPointerEvent( + tester, + const PointerCancelEvent(pointer: 21, position: Offset(24, 16)), + ); + + expect(events, isEmpty); + }); + + testWidgets('forwards only the first simultaneous touch contact', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent(pointer: 31, position: Offset(24, 16)), + ); + await sendPointerEvent( + tester, + const PointerDownEvent(pointer: 32, position: Offset(32, 16)), + ); + await sendPointerEvent( + tester, + const PointerUpEvent(pointer: 32, position: Offset(32, 16)), + ); + await sendPointerEvent( + tester, + const PointerUpEvent(pointer: 31, position: Offset(24, 16)), + ); + + expect(decodeEvents(events), '\x1b[<0;4;2M\x1b[<0;4;2m'); + }); + + testWidgets('forwards hover only in any-event tracking', (tester) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerHoverEvent( + pointer: 12, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(sgrCodes(events), [35]); + }); + + testWidgets('rejects hover in button-event tracking', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerHoverEvent( + pointer: 16, + position: Offset(24, 16), + kind: PointerDeviceKind.mouse, + ), + ); + + expect(events, isEmpty); + }); + + testWidgets('encodes vertical and horizontal wheel directions', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(8, -16), + ), + ); + + expect(sgrCodes(events), [64, 67]); + }); + + testWidgets('accepts horizontal wheel input as the first sequence', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(8, 0), + ), + ); + + expect(sgrCodes(events), [67]); + }); + + testWidgets('clears selection when tracked wheel scrolling starts', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + controller.selectAll(); + enableSgrMouseTracking(controller); + final selectionAtOutput = []; + controller.onOutput = (_) { + selectionAtOutput.add(controller.hasSelection); + }; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -16), + ), + ); + + expect(controller.hasSelection, isFalse); + expect(selectionAtOutput, isNotEmpty); + expect(selectionAtOutput, everyElement(isFalse)); + }); + + testWidgets('tracked scroll cancels selection auto-scroll', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + enableSgrMouseTracking(controller); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + commitGeometry(controller, rows: 2); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scrollable( + controller: scrollController, + viewportBuilder: (_, _) => buildHandler( + controller: controller, + scrollController: scrollController, + ), + ), + ), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final selection = await mouseDown(tester, const Offset(8, 8)); + await selection.moveTo(const Offset(8, 64)); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + final trackpad = TestPointer(1062, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, trackpad.panZoomStart(position)); + await sendPointerEvent( + tester, + trackpad.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, trackpad.panZoomEnd()); + expect(controller.hasSelection, isFalse); + await tester.pump(const Duration(milliseconds: 60)); + await selection.up(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(controller.hasSelection, isFalse); + }); + + testWidgets('applies macOS discrete wheel defaults', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -40), + ), + ); + debugDefaultTargetPlatformOverride = null; + + expect(sgrCodes(events), [64, 64, 64]); + }); + + testWidgets('encodes trackpad pan in all four wheel directions', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(30, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomUpdate(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(8, 0)), + ); + await sendPointerEvent(tester, pointer.panZoomUpdate(position)); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64, 65, 66, 67]); + }); + + testWidgets('forwards trackpad pan as precision pixel movement', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(71, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 8)), + ); + + expect(events, isEmpty); + + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64]); + }); + + testWidgets('keeps trackpad scroll at its sequence start position', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(47, PointerDeviceKind.trackpad); + const start = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(start)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + const Offset(160, 96), + pan: const Offset(16, 32), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + const Offset(320, 192), + pan: const Offset(32, 64), + ), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrPositions(events), everyElement(equals((x: 4, y: 2)))); + }); + + testWidgets('accumulates trackpad pan remainders independently', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(31, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(4, 8)), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(8, 16)), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(12, 24)), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(16, 32)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64, 66, 64, 66]); + }); + + testWidgets('shares wheel remainders with trackpad pan', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + + final pointer = TestPointer(32, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 8)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64]); + }); + + testWidgets('emits trackpad steps in vertical then horizontal order', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(33, PointerDeviceKind.trackpad); + const position = Offset(40, 32); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(-24, 32)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64, 64, 67, 67, 67]); + }); + + testWidgets('does not claim trackpad pan with physical Shift', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final pointer = TestPointer(34, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(events, isEmpty); + }); + + testWidgets('does not claim trackpad pan with virtual Shift', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + controller.toggleMod(const Mods.shift()); + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(35, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(events, isEmpty); + }); + + testWidgets('ignores trackpad scale and rotation without pan', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(36, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, scale: 1.2, rotation: 0.5), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(events, isEmpty); + }); + + testWidgets('leaves pure trackpad scale to an enclosing recognizer', ( + tester, + ) async { + enableSgrMouseTracking(controller); + var scaleUpdates = 0; + + await tester.pumpWidget( + GestureDetector( + onScaleUpdate: (_) => scaleUpdates++, + child: buildHandler(controller: controller), + ), + ); + final pointer = TestPointer(72, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, scale: 1.2, rotation: 0.5), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(scaleUpdates, greaterThan(0)); + }); + + testWidgets('does not claim trackpad pan with invalid metrics', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget( + buildHandler( + controller: controller, + metrics: const CellMetrics( + cellWidth: 0, + cellHeight: 0, + baseline: 0, + ), + ), + ); + final pointer = TestPointer(37, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 100)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(events, isEmpty); + }); + + testWidgets('does not claim trackpad pan when scrolling is disabled', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget( + buildHandler( + controller: controller, + scrollPhysics: const NeverScrollableScrollPhysics(), + ), + ); + final pointer = TestPointer(57, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(events, isEmpty); + }); + + testWidgets('disabling scrolling stops active trackpad inertia', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(64, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(0, 30), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(0, 60), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + + await tester.pumpWidget( + buildHandler( + controller: controller, + scrollPhysics: const NeverScrollableScrollPhysics(), + ), + ); + events.clear(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(events, isEmpty); + }); + + testWidgets('changing scroll physics stops active trackpad inertia', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(69, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 32)), + ); + await tester.pump(const Duration(milliseconds: 16)); + + await tester.pumpWidget( + buildHandler( + controller: controller, + scrollPhysics: const BouncingScrollPhysics(), + ), + ); + events.clear(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(events, isEmpty); + }); + + testWidgets('keeps an accepted trackpad pan after virtual Shift', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(38, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + controller.toggleMod(const Mods.shift()); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64]); + }); + + testWidgets('clears trackpad ownership at the end of a sequence', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(39, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(sgrCodes(events), [64, 64]); + }); + + testWidgets('accepts vertical pan while prior inertia is active', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final firstPointer = TestPointer(40, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, firstPointer.panZoomStart(position)); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(0, 30), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(0, 60), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final secondPointer = TestPointer(41, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + secondPointer.panZoomStart( + position, + timeStamp: const Duration(milliseconds: 57), + ), + ); + events.clear(); + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + position, + pan: const Offset(0, -100), + timeStamp: const Duration(milliseconds: 73), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 89)), + ); + + expect(sgrCodes(events), allOf(isNotEmpty, everyElement(equals(65)))); + }); + + testWidgets('accepts horizontal pan while prior inertia is active', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final firstPointer = TestPointer(42, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, firstPointer.panZoomStart(position)); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final secondPointer = TestPointer(43, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + secondPointer.panZoomStart( + position, + timeStamp: const Duration(milliseconds: 57), + ), + ); + events.clear(); + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + position, + pan: const Offset(-100, 0), + timeStamp: const Duration(milliseconds: 73), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 89)), + ); + + expect(sgrCodes(events), allOf(isNotEmpty, everyElement(equals(67)))); + }); + + testWidgets('accepts a replacement pan after inertia cancellation', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final firstPointer = TestPointer(51, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, firstPointer.panZoomStart(position)); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final secondPointer = TestPointer(52, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + firstPointer.scrollInertiaCancel( + timeStamp: const Duration(milliseconds: 56), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomStart( + position, + timeStamp: const Duration(milliseconds: 57), + ), + ); + events.clear(); + + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + position, + pan: const Offset(-100, 0), + timeStamp: const Duration(milliseconds: 73), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 89)), + ); + + expect(sgrCodes(events), allOf(isNotEmpty, everyElement(equals(67)))); + }); + + testWidgets('replacement inertia uses the new pan start position', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const firstPosition = Offset(24, 16); + final firstPointer = TestPointer(49, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + firstPointer.panZoomStart(firstPosition), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + firstPosition, + pan: const Offset(30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + firstPosition, + pan: const Offset(60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomUpdate( + firstPosition, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + firstPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + + const secondPosition = Offset(160, 96); + final secondPointer = TestPointer(50, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + secondPointer.panZoomStart( + secondPosition, + timeStamp: const Duration(milliseconds: 57), + ), + ); + events.clear(); + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + secondPosition, + pan: const Offset(-30, 0), + timeStamp: const Duration(milliseconds: 65), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + secondPosition, + pan: const Offset(-60, 0), + timeStamp: const Duration(milliseconds: 73), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomUpdate( + secondPosition, + pan: const Offset(-100, 0), + timeStamp: const Duration(milliseconds: 81), + ), + ); + await sendPointerEvent( + tester, + secondPointer.panZoomEnd(timeStamp: const Duration(milliseconds: 82)), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + sgrPositions(events), + allOf(isNotEmpty, everyElement(equals((x: 21, y: 7)))), + ); + }); + + testWidgets('horizontal inertia settles at a stable endpoint', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(45, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 32)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final movingEventCount = events.length; + expect(movingEventCount, greaterThan(0)); + + await tester.pump(const Duration(seconds: 10)); + final settledEventCount = events.length; + await tester.pump(const Duration(seconds: 1)); + + expect(events.length, settledEventCount); + }); + + testWidgets('cancels horizontal inertia on an inertia cancel signal', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(46, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 32)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final movingEventCount = events.length; + expect(movingEventCount, greaterThan(0)); + + await sendPointerEvent( + tester, + pointer.scrollInertiaCancel( + timeStamp: const Duration(milliseconds: 64), + ), + ); + await tester.pump(const Duration(seconds: 1)); + + expect(events.length, movingEventCount); + }); + + testWidgets('wheel input interrupts horizontal inertia immediately', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(48, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + events.clear(); + + await tester.sendEventToBinding( + const PointerScrollEvent( + position: position, + scrollDelta: Offset(80, 0), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(sgrCodes(events), allOf(isNotEmpty, everyElement(equals(67)))); + }); + + testWidgets('zero wheel input preserves horizontal inertia', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(63, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + events.clear(); + + await sendPointerEvent( + tester, + const PointerScrollEvent(position: position), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(events, isNotEmpty); + }); + + testWidgets('disposes active inertia when detector unmounts', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const position = Offset(24, 16); + final pointer = TestPointer(44, PointerDeviceKind.trackpad); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate( + position, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 32)), + ); + await tester.pump(const Duration(milliseconds: 16)); + + await tester.pumpWidget(const SizedBox()); + events.clear(); + await tester.pump(const Duration(seconds: 1)); + + expect(events, isEmpty); + }); + + testWidgets('accumulates wheel remainders independently by axis', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(4, -8), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(4, -8), + ), + ); + + expect(sgrCodes(events), [64, 67]); + }); + + testWidgets('emits multiple wheel steps in axis order', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(-24, 32), + ), + ); + + expect(sgrCodes(events), [65, 65, 66, 66, 66]); + }); + + testWidgets('carries signed wheel remainders across direction changes', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent(position: Offset(24, 16)), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -12), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, 20), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, 8), + ), + ); + + expect(sgrCodes(events), [65]); + }); + + testWidgets('uses the scroll signal local position', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(40, 32), + scrollDelta: Offset(0, -16), + ), + ); + + expect(utf8.decode(events.single), '\x1b[<64;6;3M'); + }); + + testWidgets('ignores wheel input when metrics are invalid', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget( + buildHandler( + controller: controller, + metrics: const CellMetrics( + cellWidth: 0, + cellHeight: 0, + baseline: 0, + ), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -100), + ), + ); + + expect(events, isEmpty); + }); + + testWidgets('resets wheel remainder when metrics change', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + await tester.pumpWidget( + buildHandler( + controller: controller, + metrics: const CellMetrics( + cellWidth: 8, + cellHeight: 8, + baseline: 6, + ), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + + expect(sgrCodes(events), [64]); + }); + + testWidgets('resets wheel remainder when tracking ends', (tester) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + writeToTerminal(controller, '\x1b[?1000l'); + await tester.pump(); + writeToTerminal(controller, '\x1b[?1000h\x1b[?1006h'); + await tester.pump(); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -8), + ), + ); + + expect(sgrCodes(events), [64]); + }); + + testWidgets('uses live mouse tracking before claiming trackpad pan', ( + tester, + ) async { + writeToTerminal(controller, 'hello'); + enableSgrMouseTracking(controller); + controller.selectAll(); + + await tester.pumpWidget(buildHandler(controller: controller)); + writeToTerminal(controller, '\x1b[?1000l'); + final pointer = TestPointer(60, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('physical Shift keeps selection ownership after release', ( + tester, + ) async { + enableSgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final gesture = await mouseDown( + tester, + const Offset(24, 16), + pointer: 17, + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await gesture.moveBy(const Offset(40, 16)); + await gesture.up(); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('tracked touch uses an independent left-button sequence', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerDownEvent( + pointer: 13, + position: Offset(24, 16), + buttons: 0, + ), + ); + await sendPointerEvent( + tester, + const PointerMoveEvent(pointer: 13, position: Offset(32, 16)), + ); + await sendPointerEvent( + tester, + const PointerUpEvent(pointer: 13, position: Offset(24, 16)), + ); + + expect(sgrCodes(events), [0, 0]); + }); + + testWidgets('tracked touch long press remains terminal-owned', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + final gesture = await tester.startGesture( + const Offset(24, 16), + pointer: 1013, + ); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + + expect(controller.hasSelection, isFalse); + + await gesture.up(); + + expect(controller.hasSelection, isFalse); + expect(sgrCodes(events), [0, 0]); + }); + + testWidgets('tracked touch scroll keeps its first contact position', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const start = Offset(24, 16); + const second = Offset(160, 96); + final firstPointer = TestPointer(53); + final secondPointer = TestPointer(54); + await sendPointerEvent(tester, firstPointer.down(start)); + await sendPointerEvent( + tester, + firstPointer.move( + start.translate(0, -64), + timeStamp: const Duration(milliseconds: 8), + ), + ); + events.clear(); + + await sendPointerEvent( + tester, + secondPointer.down( + second, + timeStamp: const Duration(milliseconds: 16), + ), + ); + await sendPointerEvent( + tester, + secondPointer.move( + second.translate(0, -64), + timeStamp: const Duration(milliseconds: 24), + ), + ); + + expect(events, isNotEmpty); + expect(sgrPositions(events), everyElement(equals((x: 4, y: 2)))); + + events.clear(); + await sendPointerEvent(tester, secondPointer.up()); + await sendPointerEvent(tester, firstPointer.up()); + }); + + testWidgets('rejects trackpad pan while touch scroll is active', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const touchPosition = Offset(24, 48); + final touch = TestPointer(65); + await sendPointerEvent(tester, touch.down(touchPosition)); + await sendPointerEvent( + tester, + touch.move( + touchPosition.translate(0, -64), + timeStamp: const Duration(milliseconds: 16), + ), + ); + events.clear(); + + const trackpadPosition = Offset(160, 96); + final trackpad = TestPointer(66, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + trackpad.panZoomStart( + trackpadPosition, + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + trackpad.panZoomUpdate( + trackpadPosition, + pan: const Offset(0, 32), + timeStamp: const Duration(milliseconds: 32), + ), + ); + await sendPointerEvent( + tester, + trackpad.panZoomEnd(timeStamp: const Duration(milliseconds: 40)), + ); + final overlappingOutput = List.of(events); + await sendPointerEvent( + tester, + touch.up(timeStamp: const Duration(milliseconds: 48)), + ); + + expect(overlappingOutput, isEmpty); + }); + + testWidgets('keeps touch scroll active after overlapping trackpad pan', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + const touchPosition = Offset(24, 48); + final touch = TestPointer(67); + await sendPointerEvent(tester, touch.down(touchPosition)); + await sendPointerEvent( + tester, + touch.move( + touchPosition.translate(0, -64), + timeStamp: const Duration(milliseconds: 16), + ), + ); + + const trackpadPosition = Offset(160, 96); + final trackpad = TestPointer(68, PointerDeviceKind.trackpad); + await sendPointerEvent( + tester, + trackpad.panZoomStart( + trackpadPosition, + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + trackpad.panZoomUpdate( + trackpadPosition, + pan: const Offset(0, 32), + timeStamp: const Duration(milliseconds: 32), + ), + ); + await sendPointerEvent( + tester, + trackpad.panZoomEnd(timeStamp: const Duration(milliseconds: 40)), + ); + events.clear(); + + await sendPointerEvent( + tester, + touch.move( + touchPosition.translate(0, -128), + timeStamp: const Duration(milliseconds: 48), + ), + ); + final continuedOutput = List.of(events); + await sendPointerEvent( + tester, + touch.up(timeStamp: const Duration(milliseconds: 56)), + ); + + expect(continuedOutput, isNotEmpty); + }); + + testWidgets('honors the configured multi-touch drag strategy', ( + tester, + ) async { + enableAnySgrMouseTracking(controller); + final events = []; + controller.onOutput = events.add; + final behavior = const ScrollBehavior().copyWith( + multitouchDragStrategy: MultitouchDragStrategy.sumAllPointers, + ); + + await tester.pumpWidget( + ScrollConfiguration( + behavior: behavior, + child: buildHandler(controller: controller), + ), + ); + const position = Offset(24, 48); + final first = TestPointer(58); + final second = TestPointer(59); + await sendPointerEvent(tester, first.down(position)); + await sendPointerEvent( + tester, + first.move( + position.translate(0, -32), + timeStamp: const Duration(milliseconds: 16), + ), + ); + events.clear(); + await sendPointerEvent( + tester, + second.down( + position.translate(16, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await sendPointerEvent( + tester, + first.move( + position.translate(0, -64), + timeStamp: const Duration(milliseconds: 32), + ), + ); + + await sendPointerEvent( + tester, + first.up(timeStamp: const Duration(milliseconds: 40)), + ); + await sendPointerEvent( + tester, + second.up(timeStamp: const Duration(milliseconds: 48)), + ); + + expect(events, isNotEmpty); + }); + + testWidgets('click fires press and release when mode is normal', ( + tester, + ) async { + enableMouseTracking(controller); + + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 16)); + await gesture.up(); + + expect(events.length, 2); + }); + + testWidgets('click fires press only when mode is x10', (tester) async { + enableMouseTracking(controller, mode: .x10); + + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 16)); + await gesture.up(); + + expect(events.length, 1); + }); + + testWidgets('no events when mode is none', (tester) async { + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + + final gesture = await mouseDown(tester, const Offset(24, 16)); + await gesture.up(); + + expect(events, isEmpty); + }); + }); + + group('alternate scroll mode', () { + testWidgets('leaves wheel selection intact when disabled', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049h\x1b[?1007lhello'); + controller.selectAll(); + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, -16), + ), + ); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('uses live alternate-scroll mode before claiming pan', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + controller.selectAll(); + + await tester.pumpWidget(buildHandler(controller: controller)); + writeToTerminal(controller, '\x1b[?1007l'); + final pointer = TestPointer(61, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('leaves selection intact for horizontal wheel input', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + controller.selectAll(); + + await tester.pumpWidget(buildHandler(controller: controller)); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(8, 0), + ), + ); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('releases unsupported horizontal wheel input to ancestors', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + var resolvedByAncestor = false; + + await tester.pumpWidget( + Listener( + onPointerSignal: (event) => GestureBinding + .instance + .pointerSignalResolver + .register(event, (_) => resolvedByAncestor = true), + child: buildHandler(controller: controller), + ), + ); + await sendPointerEvent( + tester, + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(8, 0), + ), + ); + + expect(resolvedByAncestor, isTrue); + }); + + testWidgets('releases unsupported horizontal trackpad pan to ancestors', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + var ancestorDelta = 0.0; + + await tester.pumpWidget( + GestureDetector( + onHorizontalDragUpdate: (details) => + ancestorDelta += details.delta.dx, + child: buildHandler(controller: controller), + ), + ); + final pointer = TestPointer(69, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(32, 0)), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(64, 0)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(ancestorDelta.abs(), greaterThan(0)); + }); + + testWidgets('claims supported vertical trackpad pan before ancestors', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget( + GestureDetector( + onVerticalDragUpdate: (_) {}, + child: buildHandler(controller: controller), + ), + ); + final pointer = TestPointer(70, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 32)), + ); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 64)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(events, isNotEmpty); + }); + + testWidgets('leaves selection intact for horizontal trackpad pan', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + controller.selectAll(); + + await tester.pumpWidget(buildHandler(controller: controller)); + final pointer = TestPointer(55, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(16, 0)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + + expect(controller.hasSelection, isTrue); + }); + + testWidgets('Shift bypasses tracked alternate-screen scrolling', ( + tester, + ) async { + writeToTerminal(controller, '\x1b[?1049hhello'); + enableSgrMouseTracking(controller); + controller.selectAll(); + final events = []; + controller.onOutput = events.add; + + await tester.pumpWidget(buildHandler(controller: controller)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final pointer = TestPointer(56, PointerDeviceKind.trackpad); + const position = Offset(24, 16); + await sendPointerEvent(tester, pointer.panZoomStart(position)); + await sendPointerEvent( + tester, + pointer.panZoomUpdate(position, pan: const Offset(0, 16)), + ); + await sendPointerEvent(tester, pointer.panZoomEnd()); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(events, isEmpty); + expect(controller.hasSelection, isTrue); + }); + }); + }); +} diff --git a/packages/flterm/test/widgets/terminal_input_client_test.dart b/packages/flterm/test/input/terminal_input_client_test.dart similarity index 82% rename from packages/flterm/test/widgets/terminal_input_client_test.dart rename to packages/flterm/test/input/terminal_input_client_test.dart index d6d5d370..1a01f397 100644 --- a/packages/flterm/test/widgets/terminal_input_client_test.dart +++ b/packages/flterm/test/input/terminal_input_client_test.dart @@ -1,4 +1,4 @@ -import 'package:flterm/src/widgets.dart'; +import 'package:flterm/src/input/terminal_input_client.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -152,6 +152,142 @@ void main() { expect(newlines, hasLength(1)); }); + test('deduplicates burst newline insertions after actions', () { + handler.performAction(TextInputAction.newline); + handler.performAction(TextInputAction.newline); + + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + expect(newlines, hasLength(2)); + }); + + test('deduplicates burst newline actions after insertions', () { + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + handler.performAction(TextInputAction.newline); + handler.performAction(TextInputAction.newline); + + expect(newlines, hasLength(2)); + }); + + test('deduplicates grouped newline insertion after actions', () { + handler.performAction(TextInputAction.newline); + handler.performAction(TextInputAction.newline); + + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + ]); + + expect(newlines, hasLength(2)); + }); + + test('deduplicates actions after grouped newline insertion', () { + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n\r\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange.empty, + ), + ]); + + handler.performAction(TextInputAction.newline); + handler.performAction(TextInputAction.newline); + + expect(newlines, hasLength(2)); + }); + + test('committed text ends pending newline action suppression', () { + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: 'a', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + handler.performAction(TextInputAction.newline); + + expect(newlines, hasLength(2)); + }); + + test('committed text ends pending newline delta suppression', () { + handler.performAction(TextInputAction.newline); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: 'a', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + expect(newlines, hasLength(2)); + }); + test('reports deletion character count', () { handler.updateEditingValueWithDeltas([ const TextEditingDeltaInsertion( @@ -189,6 +325,54 @@ void main() { expect(deletes, [3]); }); + test('deletion ends pending newline action suppression', () { + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaDeletion( + oldText: ' ', + deletedRange: TextRange(start: 0, end: 1), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ]); + + handler.performAction(TextInputAction.newline); + + expect(newlines, hasLength(2)); + }); + + test('deletion ends pending newline delta suppression', () { + handler.performAction(TextInputAction.newline); + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaDeletion( + oldText: ' ', + deletedRange: TextRange(start: 0, end: 1), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ]); + + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: '\n', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ), + ]); + + expect(newlines, hasLength(2)); + }); + test('does not commit composing insertion', () { handler.updateEditingValueWithDeltas([ const TextEditingDeltaInsertion( @@ -440,7 +624,7 @@ void main() { test('keeps platform text input attached after candidate commit', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.updateEditingValue( @@ -548,7 +732,7 @@ void main() { test('does not detach text input', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); handler.updateEditingValue( const TextEditingValue( text: ' \u4f60', @@ -793,6 +977,22 @@ void main() { }); group('ensureAttached', () { + test('throws when no Flutter view is set', () { + final client = TerminalInputClient(); + addTearDown(client.detach); + + expect( + client.ensureAttached, + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Text input requires an owning Flutter view before attachment.', + ), + ), + ); + }); + test('attaches without showing the keyboard', () { final calls = recordTextInputCalls(); @@ -813,10 +1013,10 @@ void main() { test('reopens a connection orphaned by another client', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); final other = TerminalInputClient()..viewId = 0; addTearDown(other.detach); - other.attach(); + other.ensureAttached(); calls.clear(); handler.ensureAttached(); @@ -827,7 +1027,7 @@ void main() { test('clears visible preedit when reopening an orphaned connection', () { final preedit = []; handler.onPreeditChanged = preedit.add; - handler.attach(); + handler.ensureAttached(); handler.updateEditingValue( const TextEditingValue( text: ' ni', @@ -837,19 +1037,52 @@ void main() { ); final other = TerminalInputClient()..viewId = 0; addTearDown(other.detach); - other.attach(); + other.ensureAttached(); preedit.clear(); handler.ensureAttached(); expect(preedit, ['']); }); + + test('uses terminal text input traits', () { + final calls = recordTextInputCalls(); + + handler.ensureAttached(keyboardAppearance: Brightness.light); + + final config = textInputConfig(calls); + expect(config['autocorrect'], isFalse); + expect(config['enableSuggestions'], isFalse); + expect( + config['smartDashesType'], + SmartDashesType.disabled.index.toString(), + ); + expect( + config['smartQuotesType'], + SmartQuotesType.disabled.index.toString(), + ); + expect(config['enableInteractiveSelection'], isFalse); + expect( + config['textCapitalization'], + TextCapitalization.none.toString(), + ); + expect(config['enableIMEPersonalizedLearning'], isFalse); + expect(config['enableInlinePrediction'], isFalse); + expect(config['enableDeltaModel'], isTrue); + expect( + (config['inputType']! as Map)['name'], + 'TextInputType.multiline', + ); + expect(config['inputAction'], TextInputAction.newline.toString()); + expect(config['keyboardAppearance'], Brightness.light.toString()); + expect(config['autofill'], isNull); + }); }); group('viewId', () { test('keeps the active connection when the view is unchanged', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.viewId = 0; @@ -859,7 +1092,7 @@ void main() { test('replaces the active connection when the view changes', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.viewId = 1; @@ -873,7 +1106,7 @@ void main() { test('uses the new view for the replacement connection', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.viewId = 1; @@ -885,7 +1118,7 @@ void main() { group('keyboardAppearance', () { test('updates the active config', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.keyboardAppearance = Brightness.light; @@ -898,7 +1131,7 @@ void main() { group('updateGeometry', () { test('reports active connection geometry', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.updateGeometry( @@ -943,69 +1176,9 @@ void main() { }); }); - group('attach', () { - test('throws when no Flutter view is set', () { - final client = TerminalInputClient(); - addTearDown(client.detach); - - expect( - client.attach, - throwsA( - isA().having( - (error) => error.message, - 'message', - 'Text input requires an owning Flutter view before attachment.', - ), - ), - ); - }); - - test('replaces an existing connection', () { - handler.attach(); - expect(handler.isAttached, isTrue); - - handler.attach(keyboardAppearance: Brightness.light); - - expect(handler.isAttached, isTrue); - }); - - test('uses terminal text input traits', () { - final calls = recordTextInputCalls(); - - handler.attach(keyboardAppearance: Brightness.light); - - final config = textInputConfig(calls); - expect(config['autocorrect'], isFalse); - expect(config['enableSuggestions'], isFalse); - expect( - config['smartDashesType'], - SmartDashesType.disabled.index.toString(), - ); - expect( - config['smartQuotesType'], - SmartQuotesType.disabled.index.toString(), - ); - expect(config['enableInteractiveSelection'], isFalse); - expect( - config['textCapitalization'], - TextCapitalization.none.toString(), - ); - expect(config['enableIMEPersonalizedLearning'], isFalse); - expect(config['enableInlinePrediction'], isFalse); - expect(config['enableDeltaModel'], isTrue); - expect( - (config['inputType']! as Map)['name'], - 'TextInputType.multiline', - ); - expect(config['inputAction'], TextInputAction.newline.toString()); - expect(config['keyboardAppearance'], Brightness.light.toString()); - expect(config['autofill'], isNull); - }); - }); - group('detach', () { test('clears the active connection', () { - handler.attach(); + handler.ensureAttached(); handler.detach(); diff --git a/packages/flterm/test/interaction/selection_gesture_driver_test.dart b/packages/flterm/test/interaction/selection_gesture_driver_test.dart new file mode 100644 index 00000000..5762559a --- /dev/null +++ b/packages/flterm/test/interaction/selection_gesture_driver_test.dart @@ -0,0 +1,98 @@ +@Tags(['ffi']) +library; + +import 'dart:typed_data'; + +import 'package:flterm/src/interaction/selection_gesture_driver.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart'; + +void main() { + group('SelectionGestureDriver', () { + late Terminal terminal; + late SelectionGestureDriver driver; + + setUp(() { + terminal = Terminal(cols: 20, rows: 5); + driver = SelectionGestureDriver(terminal); + }); + + tearDown(() { + driver.dispose(); + terminal.dispose(); + }); + + void writeUtf8(String text) { + terminal.write(Uint8List.fromList(text.codeUnits)); + } + + GridRef refAt({required int col, required int row}) { + return GridRef.at(terminal, Position(row: row, col: col)); + } + + group('press', () { + test('uses supplied event time for the repeat interval', () { + writeUtf8('alpha beta'); + driver.press( + ref: refAt(col: 1, row: 0), + pixelX: 8, + pixelY: 0, + behaviors: SelectionGestureBehaviors.standard, + wordBoundaries: null, + repeatDistance: 18, + repeatInterval: const Duration(milliseconds: 300), + timeStamp: Duration.zero, + ); + driver.release(refAt(col: 1, row: 0)); + + driver.press( + ref: refAt(col: 1, row: 0), + pixelX: 8, + pixelY: 0, + behaviors: SelectionGestureBehaviors.standard, + wordBoundaries: null, + repeatDistance: 18, + repeatInterval: const Duration(milliseconds: 300), + timeStamp: const Duration(seconds: 1), + ); + + expect(driver.behavior, SelectionGestureBehavior.cell); + }); + }); + + group('drag', () { + test('uses word boundaries from press', () { + writeUtf8('alpha_beta gamma'); + driver.press( + ref: refAt(col: 11, row: 0), + pixelX: 88, + pixelY: 0, + behaviors: const SelectionGestureBehaviors( + singleClick: .word, + doubleClick: .word, + tripleClick: .line, + ), + wordBoundaries: '_', + repeatDistance: 18, + repeatInterval: const Duration(milliseconds: 300), + timeStamp: Duration.zero, + ); + + final selection = driver.drag( + ref: refAt(col: 6, row: 0), + pixelX: 48, + pixelY: 0, + rectangle: false, + geometry: const SelectionGestureGeometry( + columns: 20, + cellWidth: 8, + paddingLeft: 0, + screenHeight: 80, + ), + ); + + expect(terminal.formatSelection(selection: selection), 'beta gamma'); + }); + }); + }); +} diff --git a/packages/flterm/test/widgets/link_interaction_test.dart b/packages/flterm/test/links/link_interaction_test.dart similarity index 97% rename from packages/flterm/test/widgets/link_interaction_test.dart rename to packages/flterm/test/links/link_interaction_test.dart index 1fcfaa40..518bf952 100644 --- a/packages/flterm/test/widgets/link_interaction_test.dart +++ b/packages/flterm/test/links/link_interaction_test.dart @@ -5,8 +5,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; +import 'package:flterm/src/links/link_interaction.dart'; import 'package:flterm/src/links/link_settings.dart'; -import 'package:flterm/src/widgets/link_interaction.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show Mods, Position, Terminal; @@ -41,7 +41,10 @@ void main() { updateLinks(); }); - tearDown(() => terminal.dispose()); + tearDown(() { + interaction.dispose(); + terminal.dispose(); + }); void write(String text) { terminal.write(Uint8List.fromList(utf8.encode(text))); diff --git a/packages/flterm/test/rendering/cursor_layer_test.dart b/packages/flterm/test/rendering/cursor_layer_test.dart index af2034e2..cfb890cd 100644 --- a/packages/flterm/test/rendering/cursor_layer_test.dart +++ b/packages/flterm/test/rendering/cursor_layer_test.dart @@ -57,8 +57,11 @@ void main() { CellMetrics metrics, TerminalTheme theme, ) async { + applyTerminalTheme(terminal, theme); final width = cols * metrics.cellWidth; final height = rows * metrics.cellHeight; + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = Size(width, height); addTearDown(() { @@ -76,10 +79,12 @@ void main() { child: TerminalRenderer( theme: theme, metrics: metrics, - terminal: terminal, + frameSource: frameSource, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(), + focused: true, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -342,14 +347,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/emoji_golden_test.dart b/packages/flterm/test/rendering/emoji_golden_test.dart index 396b29cf..23b07ea2 100644 --- a/packages/flterm/test/rendering/emoji_golden_test.dart +++ b/packages/flterm/test/rendering/emoji_golden_test.dart @@ -97,7 +97,10 @@ void main() { bool focused = true, }) async { selection?.applyTo(terminal); + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); final resolvedTheme = theme ?? emojiTheme; + applyTerminalTheme(terminal, resolvedTheme); final width = cols * metrics.cellWidth; final height = rows * metrics.cellHeight; tester.view.devicePixelRatio = 1.0; @@ -115,12 +118,14 @@ void main() { child: ConstrainedBox( constraints: BoxConstraints(maxWidth: width, maxHeight: height), child: TerminalRenderer( - terminal: terminal, + frameSource: frameSource, theme: resolvedTheme, metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(hasFocus: focused), + focused: focused, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -449,16 +454,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/helpers/font_loader.dart b/packages/flterm/test/rendering/helpers/font_loader.dart index 84d0d526..13c344c2 100644 --- a/packages/flterm/test/rendering/helpers/font_loader.dart +++ b/packages/flterm/test/rendering/helpers/font_loader.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flterm/src/foundation.dart'; +import 'package:libghostty/libghostty.dart'; + final _fontsDir = '${Directory.current.path}${Platform.pathSeparator}test' '${Platform.pathSeparator}fixtures${Platform.pathSeparator}fonts'; @@ -13,6 +16,20 @@ final _fontsDir = /// are read from the binary tables rather than estimated. Uint8List? jetBrainsMonoBytes; +/// Applies the Flutter theme's terminal colors before rendering directly. +/// +/// Renderer goldens bypass the mounted view, so they initialize the +/// terminal-facing colors explicitly at the direct-renderer test seam. +void applyTerminalTheme(Terminal terminal, TerminalTheme theme) { + terminal + ..foreground = _rgb(theme.foreground) + ..background = _rgb(theme.background) + ..cursorColor = theme.cursor.color?.fixedColor == null + ? null + : _rgb(theme.cursor.color!.fixedColor!) + ..palette = [for (var i = 0; i < 256; i++) _rgb(theme.palette[i])]; +} + /// Font family fallback list for golden tests. References only the fonts /// loaded by [loadBundledFonts] so glyph rendering does not depend on any /// platform-installed font (e.g. Apple Color Emoji), keeping output @@ -45,3 +62,9 @@ Future _load(String filename, String family) async { await ui.loadFontFromList(Uint8List.fromList(bytes), fontFamily: family); return Uint8List.fromList(bytes); } + +RgbColor _rgb(ui.Color color) => RgbColor( + (color.r * 255).round().clamp(0, 255), + (color.g * 255).round().clamp(0, 255), + (color.b * 255).round().clamp(0, 255), +); diff --git a/packages/flterm/test/rendering/sprites_golden_test.dart b/packages/flterm/test/rendering/sprites_golden_test.dart index 5e295dce..29e55331 100644 --- a/packages/flterm/test/rendering/sprites_golden_test.dart +++ b/packages/flterm/test/rendering/sprites_golden_test.dart @@ -84,6 +84,9 @@ void main() { double? maxWidth, double? maxHeight, }) { + applyTerminalTheme(terminal, theme); + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); final width = maxWidth ?? cols * metrics.cellWidth; final height = maxHeight ?? rows * metrics.cellHeight; return Directionality( @@ -93,12 +96,14 @@ void main() { child: ConstrainedBox( constraints: BoxConstraints(maxWidth: width, maxHeight: height), child: TerminalRenderer( - terminal: terminal, + frameSource: frameSource, theme: theme, metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: const _TestRenderObserver(), + focused: true, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -324,16 +329,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - const _TestRenderObserver(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/terminal_frame_source_test.dart b/packages/flterm/test/rendering/terminal_frame_source_test.dart new file mode 100644 index 00000000..6b798a80 --- /dev/null +++ b/packages/flterm/test/rendering/terminal_frame_source_test.dart @@ -0,0 +1,47 @@ +@Tags(['ffi']) +library; + +import 'package:flterm/src/rendering/terminal_frame_source.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart'; + +void main() { + group('TerminalFrameSource', () { + test('publishes terminal changes', () { + final terminal = Terminal(cols: 10, rows: 3); + final viewportChanges = ValueNotifier(0); + final source = TerminalFrameSource( + terminal, + viewportChanges: viewportChanges, + ); + addTearDown(terminal.dispose); + addTearDown(viewportChanges.dispose); + addTearDown(source.dispose); + var notifications = 0; + source.addListener(() => notifications++); + + terminal.write(Uint8List.fromList('hello'.codeUnits)); + + expect(notifications, 1); + }); + + test('publishes viewport changes', () { + final terminal = Terminal(cols: 10, rows: 3); + final viewportChanges = ValueNotifier(0); + final source = TerminalFrameSource( + terminal, + viewportChanges: viewportChanges, + ); + addTearDown(terminal.dispose); + addTearDown(viewportChanges.dispose); + addTearDown(source.dispose); + var notifications = 0; + source.addListener(() => notifications++); + + viewportChanges.value++; + + expect(notifications, 1); + }); + }); +} diff --git a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart index 66203eb1..3a930776 100644 --- a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart @@ -80,9 +80,17 @@ void main() { bool blinkVisible = true, String preeditText = '', LinkSnapshot linkSnapshot = LinkSnapshot.empty, - OnResize? onResize, + ValueChanged? onGeometryChanged, }) { + final resolvedTheme = + theme ?? + TerminalTheme.dark().copyWith( + fontFamilyFallback: bundledFontFamilyFallback, + ); + applyTerminalTheme(terminal, resolvedTheme); selection?.applyTo(terminal); + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); final width = maxWidth ?? defaultCols * metrics.cellWidth; final height = maxHeight ?? defaultRows * metrics.cellHeight; return Directionality( @@ -93,20 +101,29 @@ void main() { constraints: BoxConstraints(maxWidth: width, maxHeight: height), child: RepaintBoundary( child: TerminalRenderer( - terminal: terminal, - theme: - theme ?? - TerminalTheme.dark().copyWith( - fontFamilyFallback: bundledFontFamilyFallback, - ), + frameSource: frameSource, + theme: resolvedTheme, metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(hasFocus: focused), + focused: focused, blinkVisible: blinkVisible, preeditText: preeditText, linkSnapshot: linkSnapshot, - onResize: onResize, + onGeometryChanged: (geometry) { + terminal.resize( + cols: geometry.cols, + rows: geometry.rows, + cellWidthPx: + (geometry.cellWidth * geometry.devicePixelRatio) + .round(), + cellHeightPx: + (geometry.cellHeight * geometry.devicePixelRatio) + .round(), + ); + onGeometryChanged?.call(geometry); + }, + onViewportRowChanged: (_) {}, ), ), ), @@ -978,16 +995,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/terminal_renderer_test.dart b/packages/flterm/test/rendering/terminal_renderer_test.dart index b4df5d07..7a6bc8b2 100644 --- a/packages/flterm/test/rendering/terminal_renderer_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_test.dart @@ -38,18 +38,22 @@ void main() { Terminal terminal, { TerminalTheme? theme, CellMetrics metrics = defaultMetrics, + EdgeInsets surfacePadding = EdgeInsets.zero, TestSelection? selection, double? maxWidth, double? maxHeight, bool focused = true, bool blinkVisible = true, - OnResize? onResize, - VoidCallback? onViewportChanged, + double devicePixelRatio = 1, + ValueChanged? onGeometryChanged, + ValueChanged? onViewportRowChanged, TerminalRenderCache? renderCache, ViewportOffset? offset, }) { selection?.applyTo(terminal); renderCache ??= createRenderCache(); + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); final width = maxWidth ?? defaultCols * metrics.cellWidth; final height = maxHeight ?? defaultRows * metrics.cellHeight; return Directionality( @@ -59,15 +63,27 @@ void main() { child: ConstrainedBox( constraints: BoxConstraints(maxWidth: width, maxHeight: height), child: TerminalRenderer( - terminal: terminal, + frameSource: frameSource, theme: theme ?? TerminalTheme.dark(), metrics: metrics, + surfacePadding: surfacePadding, offset: offset ?? ViewportOffset.zero(), renderCache: renderCache, - renderObserver: _TestRenderObserver(hasFocus: focused), + devicePixelRatio: devicePixelRatio, + focused: focused, blinkVisible: blinkVisible, - onResize: onResize, - onViewportChanged: onViewportChanged, + onGeometryChanged: (geometry) { + terminal.resize( + cols: geometry.cols, + rows: geometry.rows, + cellWidthPx: (geometry.cellWidth * geometry.devicePixelRatio) + .round(), + cellHeightPx: (geometry.cellHeight * geometry.devicePixelRatio) + .round(), + ); + onGeometryChanged?.call(geometry); + }, + onViewportRowChanged: onViewportRowChanged ?? (_) {}, ), ), ), @@ -120,20 +136,105 @@ void main() { expect(box.size, isNot(equals(sizeBefore))); }); - testWidgets('onResize fires when grid dimensions change', (tester) async { - int? reportedCols; - int? reportedRows; + testWidgets('geometry callback reports the complete measured surface', ( + tester, + ) async { + TerminalResizeEvent? reportedGeometry; await tester.pumpWidget( wrap( terminal, - onResize: (cols, rows) { - reportedCols = cols; - reportedRows = rows; - }, + surfacePadding: const EdgeInsets.fromLTRB(8, 6, 4, 2), + devicePixelRatio: 2, + onGeometryChanged: (geometry) => reportedGeometry = geometry, ), ); - expect(reportedCols, defaultCols); - expect(reportedRows, defaultRows); + + expect(reportedGeometry, isNotNull); + expect(reportedGeometry!.cols, defaultCols); + expect(reportedGeometry!.rows, defaultRows); + expect(reportedGeometry!.paddingLeft, 8); + expect(reportedGeometry!.paddingBottom, 2); + expect(reportedGeometry!.devicePixelRatio, 2); + }); + + testWidgets('geometry callback fires when physical cell geometry changes', ( + tester, + ) async { + var resizeCount = 0; + await tester.pumpWidget( + wrap( + terminal, + onGeometryChanged: (_) => resizeCount++, + maxWidth: defaultCols * altMetrics.cellWidth, + maxHeight: defaultRows * altMetrics.cellHeight, + ), + ); + await tester.pumpWidget( + wrap( + terminal, + metrics: altMetrics, + onGeometryChanged: (_) => resizeCount++, + maxWidth: defaultCols * altMetrics.cellWidth, + maxHeight: defaultRows * altMetrics.cellHeight, + ), + ); + + expect(resizeCount, 2); + }); + + testWidgets('geometry callback fires when surface padding changes', ( + tester, + ) async { + var resizeCount = 0; + await tester.pumpWidget( + wrap(terminal, onGeometryChanged: (_) => resizeCount++), + ); + await tester.pumpWidget( + wrap( + terminal, + surfacePadding: const EdgeInsets.fromLTRB(8, 6, 4, 2), + onGeometryChanged: (_) => resizeCount++, + ), + ); + + expect(resizeCount, 2); + }); + + testWidgets('clears layout state when the geometry callback throws', ( + tester, + ) async { + final error = StateError('geometry failed'); + + await tester.pumpWidget( + wrap(terminal, onGeometryChanged: (_) => throw error), + ); + expect(tester.takeException(), same(error)); + + await tester.pumpWidget(wrap(terminal)); + + expect( + tester + .renderObject(find.byType(TerminalRenderer)) + .size, + const Size(200, 80), + ); + }); + + testWidgets('geometry callback initializes a replacement terminal', ( + tester, + ) async { + final replacement = Terminal(cols: defaultCols, rows: defaultRows); + addTearDown(replacement.dispose); + + await tester.pumpWidget(wrap(terminal)); + await tester.pumpWidget(wrap(replacement)); + + expect(replacement.geometry, ( + cols: defaultCols, + rows: defaultRows, + widthPx: defaultCols * defaultMetrics.cellWidth.toInt(), + heightPx: defaultRows * defaultMetrics.cellHeight.toInt(), + )); }); testWidgets('theme change triggers layout', (tester) async { @@ -231,20 +332,16 @@ void main() { List.filled(20, 'scrollback row\r\n').join().codeUnits, ), ); - var notifications = 0; + final requestedRows = []; await tester.pumpWidget( - wrap( - terminal, - offset: offset, - onViewportChanged: () => notifications++, - ), + wrap(terminal, offset: offset, onViewportRowChanged: requestedRows.add), ); - notifications = 0; + requestedRows.clear(); offset.jumpTo(0); await tester.pump(); - expect(notifications, 1); + expect(requestedRows, [0]); }); }); } @@ -259,19 +356,6 @@ class _TrackingRenderCache extends TerminalRenderCache { } } -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} - class _TestViewportOffset extends ViewportOffset { double _pixels = 0; diff --git a/packages/flterm/test/rendering/transparent_background_golden_test.dart b/packages/flterm/test/rendering/transparent_background_golden_test.dart index 58176930..d11d0c73 100644 --- a/packages/flterm/test/rendering/transparent_background_golden_test.dart +++ b/packages/flterm/test/rendering/transparent_background_golden_test.dart @@ -49,6 +49,9 @@ void main() { }) async { final terminal = Terminal(cols: cols, rows: rows); addTearDown(terminal.dispose); + applyTerminalTheme(terminal, theme); + final frameSource = TerminalFrameSource(terminal); + addTearDown(frameSource.dispose); writeUtf8(terminal, content); tester.view.devicePixelRatio = 1.0; @@ -72,12 +75,14 @@ void main() { alpha: theme.backgroundOpacity, ), child: TerminalRenderer( - terminal: terminal, + frameSource: frameSource, theme: theme, metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: const _Observer(), + focused: true, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -150,16 +155,3 @@ void main() { }); }); } - -class _Observer implements TerminalRenderObserver { - const _Observer(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/widgets/compression_scheduler_test.dart b/packages/flterm/test/view/compression_scheduler_test.dart similarity index 99% rename from packages/flterm/test/widgets/compression_scheduler_test.dart rename to packages/flterm/test/view/compression_scheduler_test.dart index c25e1d18..0cc8a7cd 100644 --- a/packages/flterm/test/widgets/compression_scheduler_test.dart +++ b/packages/flterm/test/view/compression_scheduler_test.dart @@ -1,5 +1,5 @@ import 'package:fake_async/fake_async.dart'; -import 'package:flterm/src/widgets/compression_scheduler.dart'; +import 'package:flterm/src/view/compression_scheduler.dart'; import 'package:flutter/foundation.dart' show VoidCallback; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show TerminalCompressionResult; diff --git a/packages/flterm/test/view/terminal_cursor_blink_test.dart b/packages/flterm/test/view/terminal_cursor_blink_test.dart new file mode 100644 index 00000000..ea9e8c10 --- /dev/null +++ b/packages/flterm/test/view/terminal_cursor_blink_test.dart @@ -0,0 +1,34 @@ +import 'package:flterm/src/view/terminal_cursor_blink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('toggles while enabled and resets visible when disabled', ( + tester, + ) async { + final blink = TerminalCursorBlink(); + + blink.sync(enabled: true, interval: const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(blink.value, isFalse); + + blink.sync(enabled: false, interval: const Duration(milliseconds: 100)); + expect(blink.value, isTrue); + await tester.pump(const Duration(milliseconds: 200)); + expect(blink.value, isTrue); + blink.dispose(); + }); + + testWidgets('sync restarts the blink interval', (tester) async { + final blink = TerminalCursorBlink(); + const interval = Duration(milliseconds: 100); + + blink.sync(enabled: true, interval: interval); + await tester.pump(const Duration(milliseconds: 75)); + blink.sync(enabled: true, interval: interval); + await tester.pump(const Duration(milliseconds: 75)); + expect(blink.value, isTrue); + await tester.pump(const Duration(milliseconds: 25)); + expect(blink.value, isFalse); + blink.dispose(); + }); +} diff --git a/packages/flterm/test/widgets/terminal_scroll_controller_test.dart b/packages/flterm/test/view/terminal_scroll_controller_test.dart similarity index 97% rename from packages/flterm/test/widgets/terminal_scroll_controller_test.dart rename to packages/flterm/test/view/terminal_scroll_controller_test.dart index ddf552c0..43ff4118 100644 --- a/packages/flterm/test/widgets/terminal_scroll_controller_test.dart +++ b/packages/flterm/test/view/terminal_scroll_controller_test.dart @@ -1,4 +1,4 @@ -import 'package:flterm/src/widgets.dart'; +import 'package:flterm/src/view/terminal_scroll_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show TerminalScreen; import 'package:material_ui/material_ui.dart'; @@ -95,6 +95,7 @@ void main() { controller.activeScreen = .alternate; await tester.pumpWidget(buildScrollable(controller)); + expect(controller.position.pixels, 0); controller.jumpTo(9999); await tester.pump(); diff --git a/packages/flterm/test/widgets/terminal_shortcut_scope_test.dart b/packages/flterm/test/view/terminal_shortcut_scope_test.dart similarity index 94% rename from packages/flterm/test/widgets/terminal_shortcut_scope_test.dart rename to packages/flterm/test/view/terminal_shortcut_scope_test.dart index 47470060..8688254b 100644 --- a/packages/flterm/test/widgets/terminal_shortcut_scope_test.dart +++ b/packages/flterm/test/view/terminal_shortcut_scope_test.dart @@ -3,7 +3,8 @@ library; import 'dart:convert'; -import 'package:flterm/src/widgets.dart'; +import 'package:flterm/src/controller/terminal_controller.dart'; +import 'package:flterm/src/view/terminal_shortcut_scope.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,10 @@ void main() { terminal.write(Uint8List.fromList(utf8.encode(text))); } + Terminal terminalFor(TerminalController controller) { + return (controller as TerminalControllerImpl).terminal; + } + Widget buildScope( Map shortcuts, { VoidCallback? onPaste, @@ -58,7 +63,7 @@ void main() { group('copy', () { testWidgets('copies selected text to clipboard', (tester) async { - writeUtf8(controller.terminal, 'hello world'); + writeUtf8(terminalFor(controller), 'hello world'); controller.selectRange( start: const Position(row: 0, col: 0), end: const Position(row: 0, col: 4), @@ -136,7 +141,7 @@ void main() { group('selectAll', () { testWidgets('selects all terminal content', (tester) async { - writeUtf8(controller.terminal, 'hello'); + writeUtf8(terminalFor(controller), 'hello'); await tester.pumpWidget(buildScope(macShortcuts())); await tester.pumpAndSettle(); @@ -148,7 +153,7 @@ void main() { }); testWidgets('leaves selection empty when disabled', (tester) async { - writeUtf8(controller.terminal, 'hello'); + writeUtf8(terminalFor(controller), 'hello'); await tester.pumpWidget( buildScope(macShortcuts(), enableSelectAll: false), @@ -163,7 +168,7 @@ void main() { group('clear', () { testWidgets('emits form feed output', (tester) async { - writeUtf8(controller.terminal, 'hello\r\nworld\r\n'); + writeUtf8(terminalFor(controller), 'hello\r\nworld\r\n'); final output = []; controller.onOutput = output.add; diff --git a/packages/flterm/test/view/terminal_view_attachment_test.dart b/packages/flterm/test/view/terminal_view_attachment_test.dart new file mode 100644 index 00000000..b3c67be3 --- /dev/null +++ b/packages/flterm/test/view/terminal_view_attachment_test.dart @@ -0,0 +1,162 @@ +@Tags(['ffi']) +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flterm/src/controller/terminal_controller.dart'; +import 'package:flterm/src/foundation.dart'; +import 'package:flterm/src/view/terminal_view_attachment.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' show Mods, RgbColor, TerminalScreen; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('TerminalViewAttachment', () { + late TerminalControllerImpl controller; + late TerminalViewAttachment attachment; + + setUp(() { + controller = TerminalControllerImpl(); + attachment = TerminalViewAttachment(controller); + }); + + tearDown(() { + attachment.dispose(); + controller.dispose(); + }); + + RgbColor rgb(Color color) => RgbColor( + (color.r * 255).round().clamp(0, 255), + (color.g * 255).round().clamp(0, 255), + (color.b * 255).round().clamp(0, 255), + ); + + test('exposes controller terminal state without changing ownership', () { + expect(attachment.terminal, same(controller.terminal)); + expect(attachment.virtualMods, const Mods.none()); + }); + + test('rejects a second active view attachment', () { + expect( + () => TerminalViewAttachment(controller), + throwsA(isA()), + ); + }); + + test('stale attachment disposal does not detach a newer view', () { + final first = attachment; + first.dispose(); + final second = TerminalViewAttachment(controller); + attachment = second; + + first.dispose(); + + expect( + () => TerminalViewAttachment(controller), + throwsA(isA()), + ); + }); + + test('projects only interaction changes', () { + var notifications = 0; + attachment.interaction.addListener(() => notifications++); + + controller.toggleMod(const Mods.ctrl()); + + expect(notifications, 0); + + controller.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + + expect(notifications, 1); + expect( + attachment.interaction.value.activeScreen, + TerminalScreen.alternate, + ); + }); + + test('publishes controller changes without broad interaction rebuilds', () { + var attachmentNotifications = 0; + var interactionNotifications = 0; + attachment.addListener(() => attachmentNotifications++); + attachment.interaction.addListener(() => interactionNotifications++); + + controller.toggleMod(const Mods.ctrl()); + + expect(attachmentNotifications, 1); + expect(interactionNotifications, 0); + }); + + test('applies view theme colors to the terminal session', () { + final theme = TerminalTheme.dark(); + + attachment.applyTheme(theme); + + expect(attachment.terminal.foreground, rgb(theme.foreground)); + expect(attachment.terminal.background, rgb(theme.background)); + expect(attachment.terminal.palette[1], rgb(theme.palette[1])); + }); + + test('applies viewport row intents to the terminal session', () { + controller.write( + Uint8List.fromList( + List.filled(40, 'scrollback row\r\n').join().codeUnits, + ), + ); + + attachment.handleViewportRowChanged(0); + + expect(attachment.terminal.scrollbar.offset, 0); + }); + + testWidgets('attaches and detaches view services locally', (tester) async { + final focusNode = _InspectableFocusNode(); + final scrollController = ScrollController(); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Focus(focusNode: focusNode, child: const SizedBox()), + ); + attachment.attach(focusNode, scrollController, viewId: 0); + + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + + attachment.detach(); + + expect(attachment.input.preeditText, isEmpty); + }); + + testWidgets('does not duplicate a focus listener when reattached', ( + tester, + ) async { + final focusNode = _InspectableFocusNode(); + final scrollController = ScrollController(); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Focus(focusNode: focusNode, child: const SizedBox()), + ); + + final initiallyHasListeners = focusNode.hasFocusListeners; + attachment.attach(focusNode, scrollController, viewId: 0); + final hasListenersAfterAttach = focusNode.hasFocusListeners; + + attachment.attach(focusNode, scrollController, viewId: 1); + + expect(focusNode.hasFocusListeners, hasListenersAfterAttach); + attachment.detach(); + expect(focusNode.hasFocusListeners, initiallyHasListeners); + }); + }); +} + +final class _InspectableFocusNode extends FocusNode { + bool get hasFocusListeners => hasListeners; +} diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/view/terminal_view_test.dart similarity index 55% rename from packages/flterm/test/widgets/terminal_view_test.dart rename to packages/flterm/test/view/terminal_view_test.dart index c2207937..e53db1bd 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/view/terminal_view_test.dart @@ -1,9 +1,13 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flterm/src/controller/terminal_controller.dart'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/links/link_settings.dart'; import 'package:flterm/src/rendering.dart'; -import 'package:flterm/src/widgets.dart'; +import 'package:flterm/src/view/terminal_scope.dart'; +import 'package:flterm/src/view/terminal_scroll_controller.dart'; +import 'package:flterm/src/view/terminal_view.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, @@ -66,12 +70,12 @@ void main() { controller.write(Uint8List.fromList(utf8.encode(text))); } - Selection? activeSelection(TerminalController controller) { - return (controller as TerminalViewBinding).terminal.selection; + Terminal terminal(TerminalController controller) { + return (controller as TerminalControllerImpl).terminal; } - Terminal terminal(TerminalController controller) { - return (controller as TerminalViewBinding).terminal; + Selection? activeSelection(TerminalController controller) { + return terminal(controller).selection; } String decodeOutput(List output) { @@ -127,6 +131,15 @@ void main() { } } + Future withWindowsPlatform(Future Function() body) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + Future releaseControlIfPressed(WidgetTester tester) async { if (!HardwareKeyboard.instance.isControlPressed) return; await tester.sendKeyUpEvent(LogicalKeyboardKey.control); @@ -134,6 +147,7 @@ void main() { Widget wrapInApp({ required TerminalController controller, + FocusNode? focusNode, TerminalTheme? theme, TerminalScrollController? scrollController, bool autofocus = false, @@ -144,6 +158,7 @@ void main() { EdgeInsets padding = EdgeInsets.zero, double width = 800, double height = 480, + Uint8List? fontData, }) { return MaterialApp( home: Scaffold( @@ -152,6 +167,7 @@ void main() { height: height, child: TerminalView( controller: controller, + focusNode: focusNode, theme: theme, scrollController: scrollController, autofocus: autofocus, @@ -160,6 +176,7 @@ void main() { gestureSettings: gestureSettings, linkSettings: linkSettings, padding: padding, + fontData: fontData, ), ), ), @@ -299,19 +316,57 @@ void main() { expect(rows.last, greaterThan(0)); }); + testWidgets('geometry uses the Flutter view device pixel ratio', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final overriddenMediaQuery = MediaQuery.of( + context, + ).copyWith(devicePixelRatio: 7); + return Scaffold( + body: MediaQuery( + data: overriddenMediaQuery, + child: SizedBox( + width: 800, + height: 480, + child: TerminalView(controller: controller), + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(renderer(tester).devicePixelRatio, tester.view.devicePixelRatio); + }); + testWidgets('tap to focus', (tester) async { - await tester.pumpWidget(wrapInApp(controller: controller)); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final calls = recordTextInputCalls(); + await tester.pumpWidget( + wrapInApp(controller: controller, focusNode: focusNode), + ); - expect(controller.hasFocus, isFalse); + expect(focusNode.hasFocus, isFalse); await tester.tap(find.byType(TerminalView)); await tester.pumpAndSettle(); - expect(controller.hasFocus, isTrue); - expect(controller.keyboardState, KeyboardState.showing); + expect(focusNode.hasFocus, isTrue); + expect( + calls.where((call) => call.method == 'TextInput.show'), + isNotEmpty, + ); }); testWidgets('alternate screen keeps soft keyboard enabled', (tester) async { + final calls = recordTextInputCalls(); await tester.pumpWidget( wrapInApp(controller: controller, autofocus: true), ); @@ -320,17 +375,25 @@ void main() { writeUtf8(controller, '\x1b[?1049h'); await tester.pump(); - expect(controller.keyboardState, KeyboardState.showing); + expect( + calls.where((call) => call.method == 'TextInput.show'), + isNotEmpty, + ); }); testWidgets('autofocus focuses on mount', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), + wrapInApp( + controller: controller, + focusNode: focusNode, + autofocus: true, + ), ); await tester.pump(); - expect(controller.hasFocus, isTrue); - expect(controller.keyboardState, KeyboardState.showing); + expect(focusNode.hasFocus, isTrue); }); testWidgets('scrolling into scrollback keeps the cursor visible', ( @@ -341,10 +404,7 @@ void main() { scrollController.jumpTo(0); await tester.pump(); - expect( - (controller as TerminalViewBinding).terminal.isViewportActive, - isFalse, - ); + expect(terminal(controller).isViewportActive, isFalse); expect(renderer(tester).blinkVisible, isTrue); await tester.pump(const Duration(milliseconds: 11)); @@ -363,10 +423,7 @@ void main() { scrollController.jumpTo(scrollController.position.maxScrollExtent); await tester.pump(); - expect( - (controller as TerminalViewBinding).terminal.isViewportActive, - isTrue, - ); + expect(terminal(controller).isViewportActive, isTrue); await tester.pump(const Duration(milliseconds: 11)); await tester.pump(); @@ -374,6 +431,41 @@ void main() { expect(renderer(tester).blinkVisible, isFalse); }); + testWidgets('focus loss stops cursor blinking in the visible phase', ( + tester, + ) async { + controller.dispose(); + controller = TerminalController( + config: const TerminalConfig(cursorBlink: true), + ); + final focusNode = FocusNode(); + final theme = TerminalTheme.dark().copyWith( + cursor: const CursorTheme(blinkInterval: Duration(milliseconds: 10)), + ); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + wrapInApp( + controller: controller, + focusNode: focusNode, + theme: theme, + autofocus: true, + showKeyboard: false, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 11)); + await tester.pump(); + expect(renderer(tester).blinkVisible, isFalse); + + focusNode.unfocus(); + await tester.pump(); + await tester.pump(); + + expect(focusNode.hasFocus, isFalse); + expect(renderer(tester).focused, isFalse); + expect(renderer(tester).blinkVisible, isTrue); + }); + testWidgets('text input produces output via onOutput', (tester) async { final output = []; controller.onOutput = output.add; @@ -577,6 +669,79 @@ void main() { expect(decodeOutput(output), ':'); }); + testWidgets('AltGr printable input emits committed text once', ( + tester, + ) async { + await withWindowsPlatform(() async { + final output = []; + controller.onOutput = output.add; + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true), + ); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altRight); + await tester.sendKeyEvent( + LogicalKeyboardKey.keyQ, + physicalKey: PhysicalKeyboardKey.keyQ, + character: '@', + ); + tester.testTextInput.enterText('@'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(decodeOutput(output), '@'); + }); + }); + + testWidgets('Kitty keyboard input reports Caps Lock', (tester) async { + writeUtf8(controller, '\x1b[=31u'); + final output = []; + controller.onOutput = output.add; + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true, showKeyboard: false), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); + output.clear(); + + await tester.sendKeyDownEvent( + LogicalKeyboardKey.keyJ, + physicalKey: PhysicalKeyboardKey.keyJ, + character: 'J', + ); + final encoded = decodeOutput(output); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); + + expect(encoded, '\x1b[106;65;74u'); + }); + + testWidgets('Kitty keyboard input reports Num Lock', (tester) async { + writeUtf8(controller, '\x1b[=31u'); + final output = []; + controller.onOutput = output.add; + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true, showKeyboard: false), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.numLock); + output.clear(); + + await tester.sendKeyDownEvent( + LogicalKeyboardKey.keyJ, + physicalKey: PhysicalKeyboardKey.keyJ, + character: 'j', + ); + final encoded = decodeOutput(output); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyJ); + await tester.sendKeyEvent(LogicalKeyboardKey.numLock); + + expect(encoded, '\x1b[106;129;106u'); + }); + testWidgets('composition updates preedit without output', (tester) async { final output = []; controller.onOutput = output.add; @@ -595,7 +760,12 @@ void main() { ); await tester.pump(); - expect((controller as TerminalViewBinding).preeditText, 'ni'); + expect( + tester + .widget(find.byType(TerminalRenderer)) + .preeditText, + 'ni', + ); expect(output, isEmpty); }); @@ -741,12 +911,17 @@ void main() { ); await tester.pump(); - expect((controller as TerminalViewBinding).preeditText, ''); + expect( + tester + .widget(find.byType(TerminalRenderer)) + .preeditText, + isEmpty, + ); expect(utf8.decode(output.single), '日'); }); testWidgets( - 'desktop backspace after candidate commit forwards to platform IME', + 'desktop backspace with Caps Lock forwards after candidate commit', (tester) async { await withMacOSPlatform(() async { final calls = recordTextInputCalls(); @@ -776,6 +951,7 @@ void main() { ), ); await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); calls.clear(); output.clear(); @@ -783,6 +959,7 @@ void main() { LogicalKeyboardKey.backspace, ); await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); expect(handled, isFalse); expect(decodeOutput(output), '\x08'); @@ -913,12 +1090,18 @@ void main() { }); testWidgets('unmount clears focus state', (tester) async { - await tester.pumpWidget(wrapInApp(controller: controller)); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + wrapInApp(controller: controller, focusNode: focusNode), + ); + focusNode.requestFocus(); + await tester.pump(); await tester.pumpWidget(const MaterialApp(home: SizedBox())); await tester.pumpAndSettle(); - expect(controller.hasFocus, isFalse); + expect(focusNode.hasFocus, isFalse); }); testWidgets('changing theme updates metrics', (tester) async { @@ -941,6 +1124,24 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('changing font data updates metrics', (tester) async { + final theme = TerminalTheme.dark().copyWith( + fontFamily: 'Missing Font', + fontSize: 24, + ); + final fontData = File( + 'test/fixtures/fonts/JetBrainsMono-Regular.ttf', + ).readAsBytesSync(); + await tester.pumpWidget(wrapInApp(controller: controller, theme: theme)); + final initialMetrics = renderer(tester).metrics; + + await tester.pumpWidget( + wrapInApp(controller: controller, theme: theme, fontData: fontData), + ); + + expect(renderer(tester).metrics, isNot(initialMetrics)); + }); + testWidgets('reports light color scheme for perceived-light backgrounds', ( tester, ) async { @@ -984,10 +1185,108 @@ void main() { await tester.pumpWidget(wrapInApp(controller: controller2)); await tester.pumpAndSettle(); - expect(controller.hasFocus, isFalse); expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('changing controller reports focus loss to the old terminal', ( + tester, + ) async { + final controller2 = TerminalController(); + final output = []; + controller.onOutput = output.add; + writeUtf8(controller, '\x1b[?1004h'); + addTearDown(controller2.dispose); + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true), + ); + await tester.pump(); + output.clear(); + + await tester.pumpWidget( + wrapInApp(controller: controller2, autofocus: true), + ); + + expect(decodeOutput(output), FocusEvent.lost.encode()); + }); + + testWidgets('changing controller reports focus gain to the new terminal', ( + tester, + ) async { + final controller2 = TerminalController(); + final output = []; + controller2.onOutput = output.add; + writeUtf8(controller2, '\x1b[?1004h'); + addTearDown(controller2.dispose); + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true), + ); + await tester.pump(); + + await tester.pumpWidget( + wrapInApp(controller: controller2, autofocus: true), + ); + + expect(decodeOutput(output), FocusEvent.gained.encode()); + }); + + testWidgets('controller and focus node change atomically', (tester) async { + final calls = recordTextInputCalls(); + final controller2 = TerminalController(); + final firstFocusNode = FocusNode(); + final secondFocusNode = FocusNode(); + addTearDown(controller2.dispose); + addTearDown(firstFocusNode.dispose); + addTearDown(secondFocusNode.dispose); + + await tester.pumpWidget( + wrapInApp( + controller: controller, + focusNode: firstFocusNode, + autofocus: true, + ), + ); + await tester.pump(); + calls.clear(); + + await tester.pumpWidget( + wrapInApp(controller: controller2, focusNode: secondFocusNode), + ); + await tester.pump(); + + expect(firstFocusNode.hasFocus, isFalse); + expect(secondFocusNode.hasFocus, isFalse); + expect( + calls.where((call) => call.method == 'TextInput.clearClient'), + hasLength(1), + ); + expect( + calls.where((call) => call.method == 'TextInput.setClient'), + isEmpty, + ); + }); + + testWidgets('syncs the scroll screen when changing to an alternate TUI', ( + tester, + ) async { + final controller2 = TerminalController(); + final scrollController = TerminalScrollController(); + addTearDown(controller2.dispose); + addTearDown(scrollController.dispose); + writeUtf8(controller2, '\x1b[?1049h'); + + await tester.pumpWidget( + wrapInApp(controller: controller, scrollController: scrollController), + ); + await tester.pump(); + + await tester.pumpWidget( + wrapInApp(controller: controller2, scrollController: scrollController), + ); + await tester.pump(); + + expect(scrollController.activeScreen, TerminalScreen.alternate); + }); + testWidgets('changing scrollController keeps the view mounted', ( tester, ) async { @@ -1037,16 +1336,21 @@ void main() { tester, ) async { final calls = recordTextInputCalls(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); await tester.pumpWidget( - wrapInApp(controller: controller, showKeyboard: false), + wrapInApp( + controller: controller, + focusNode: focusNode, + showKeyboard: false, + ), ); await tester.tap(find.byType(TerminalView)); await tester.pumpAndSettle(); - expect(controller.hasFocus, isTrue); - expect(controller.keyboardState, KeyboardState.hidden); + expect(focusNode.hasFocus, isTrue); expect( calls.where((call) => call.method == 'TextInput.setClient'), hasLength(1), @@ -1102,6 +1406,40 @@ void main() { expect(lifecycleCalls(calls), isEmpty); }); + + testWidgets('closes the connection when the focus node changes', ( + tester, + ) async { + final calls = recordTextInputCalls(); + final firstFocusNode = FocusNode(); + final secondFocusNode = FocusNode(); + addTearDown(firstFocusNode.dispose); + addTearDown(secondFocusNode.dispose); + + await tester.pumpWidget( + wrapInApp( + controller: controller, + focusNode: firstFocusNode, + autofocus: true, + ), + ); + await tester.pump(); + calls.clear(); + + await tester.pumpWidget( + wrapInApp(controller: controller, focusNode: secondFocusNode), + ); + await tester.pump(); + + expect( + calls.where((call) => call.method == 'TextInput.clearClient'), + hasLength(1), + ); + expect( + calls.where((call) => call.method == 'TextInput.setClient'), + isEmpty, + ); + }); }); testWidgets('touch drag does not create selection', (tester) async { @@ -1327,27 +1665,944 @@ void main() { ); } - testWidgets('scroll event changes scroll offset', (tester) async { - final fixture = await pumpScrollableTerminal(tester); - final initialPixels = fixture.scrollController.position.pixels; - final center = tester.getCenter(find.byType(TerminalView)); + List sgrMouseCodes(List output) { + return RegExp('\x1b\\[<(\\d+);') + .allMatches(decodeOutput(output)) + .map((match) => int.parse(match.group(1)!)) + .toList(); + } - await tester.sendEventToBinding( - PointerScrollEvent( - position: center, - scrollDelta: const Offset(0, -100), + List sgrMouseCodesAfter(List output, int index) { + return sgrMouseCodes(output.sublist(index)); + } + + List<({int x, int y})> sgrMousePositions(List output) { + return RegExp('\x1b\\[<\\d+;(\\d+);(\\d+)[Mm]') + .allMatches(decodeOutput(output)) + .map( + (match) => ( + x: int.parse(match.group(1)!), + y: int.parse(match.group(2)!), + ), + ) + .toList(); + } + + testWidgets('initializes an alternate-screen scroll position', ( + tester, + ) async { + writeUtf8(controller, '\x1b[?1049h'); + final scrollController = TerminalScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + wrapInApp( + controller: controller, + scrollController: scrollController, + showKeyboard: false, ), ); - await tester.pumpAndSettle(); + await tester.pump(); - expect(fixture.scrollController.position.pixels, isNot(initialPixels)); + final position = scrollController.position; + expect( + (position as TerminalScrollPosition).activeScreen, + TerminalScreen.alternate, + ); + expect(position.minScrollExtent, double.negativeInfinity); + expect(position.maxScrollExtent, double.infinity); }); - testWidgets('pixel offset maps to terminal row after existing scroll', ( + testWidgets('restores primary scroll state after alternate screen', ( tester, ) async { final fixture = await pumpScrollableTerminal(tester); - final targetRow = fixture.terminal.scrollbackRows ~/ 2; + fixture.scrollController.jumpTo(0); + await tester.pump(); + final primaryPixels = fixture.scrollController.position.pixels; + + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); + writeUtf8(controller, '\x1b[?1049l'); + await tester.pump(); + await tester.pump(); + + expect( + fixture.scrollController.position.pixels, + closeTo(primaryPixels, 0.01), + ); + }); + + Future sendHorizontalTrackpadFling( + WidgetTester tester, + TestPointer pointer, + Offset position, + Duration startTime, + ({double first, double second, double third}) pan, + ) async { + await tester.sendEventToBinding( + pointer.panZoomStart(position, timeStamp: startTime), + ); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: Offset(pan.first, 0), + timeStamp: startTime + const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: Offset(pan.second, 0), + timeStamp: startTime + const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: Offset(pan.third, 0), + timeStamp: startTime + const Duration(milliseconds: 24), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomEnd( + timeStamp: startTime + const Duration(milliseconds: 25), + ), + ); + await tester.pump(); + } + + Future sendPausedHorizontalTrackpadFling( + WidgetTester tester, + TestPointer pointer, + Offset position, + Duration startTime, + ) async { + await tester.sendEventToBinding( + pointer.panZoomStart(position, timeStamp: startTime), + ); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: startTime + const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: const Offset(100, 0), + timeStamp: startTime + const Duration(milliseconds: 33), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: const Offset(200, 0), + timeStamp: startTime + const Duration(milliseconds: 41), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + position, + pan: const Offset(300, 0), + timeStamp: startTime + const Duration(milliseconds: 49), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomEnd( + timeStamp: startTime + const Duration(milliseconds: 50), + ), + ); + await tester.pump(); + } + + testWidgets('scroll event changes scroll offset', (tester) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + final center = tester.getCenter(find.byType(TerminalView)); + + await tester.sendEventToBinding( + PointerScrollEvent( + position: center, + scrollDelta: const Offset(0, -100), + ), + ); + await tester.pumpAndSettle(); + + expect(fixture.scrollController.position.pixels, isNot(initialPixels)); + }); + + testWidgets('tracked scroll claims the signal before local scrolling', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + writeUtf8(controller, '\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding( + PointerScrollEvent( + position: center, + scrollDelta: const Offset(0, -100), + ), + ); + await tester.pumpAndSettle(); + + expect(decodeOutput(output), contains('\x1b[<64;')); + expect(fixture.scrollController.position.pixels, initialPixels); + }); + + testWidgets('tracked trackpad pan claims the gesture before scrolling', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + writeUtf8(controller, '\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final pointer = TestPointer(100, PointerDeviceKind.trackpad); + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, 100)), + ); + await tester.pumpAndSettle(); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + expect(decodeOutput(output), contains('\x1b[<64;')); + expect(fixture.scrollController.position.pixels, initialPixels); + }); + + testWidgets('trackpad fling continues with terminal momentum', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(104, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 30), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 60), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await tester.pump(); + final initialReportCount = sgrMouseCodes(output).length; + await tester.sendEventToBinding( + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(sgrMouseCodes(output).length, greaterThan(initialReportCount)); + }); + + testWidgets('wheel input interrupts vertical momentum immediately', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(115, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 30), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 60), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: const Offset(0, 100), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await tester.sendEventToBinding( + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + output.clear(); + + await tester.sendEventToBinding( + PointerScrollEvent( + position: center, + scrollDelta: const Offset(0, 160), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + sgrMouseCodes(output), + allOf(isNotEmpty, everyElement(equals(65))), + ); + }); + + testWidgets('repeated horizontal flings carry active momentum', ( + tester, + ) async { + await withMacOSPlatform(() async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final singlePointer = TestPointer(108, PointerDeviceKind.trackpad); + await sendHorizontalTrackpadFling( + tester, + singlePointer, + center, + Duration.zero, + (first: 100, second: 200, third: 300), + ); + output.clear(); + await tester.pump(const Duration(milliseconds: 100)); + final singleFlingReportCount = sgrMouseCodes(output).length; + await tester.sendEventToBinding( + singlePointer.scrollInertiaCancel( + timeStamp: const Duration(milliseconds: 150), + ), + ); + await tester.pump(); + + await sendHorizontalTrackpadFling( + tester, + TestPointer(109, PointerDeviceKind.trackpad), + center, + const Duration(milliseconds: 200), + (first: 30, second: 60, third: 100), + ); + await tester.pump(const Duration(milliseconds: 32)); + await sendHorizontalTrackpadFling( + tester, + TestPointer(110, PointerDeviceKind.trackpad), + center, + const Duration(milliseconds: 257), + (first: 100, second: 200, third: 300), + ); + output.clear(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + sgrMouseCodes(output).length, + greaterThan(singleFlingReportCount + 1), + ); + }); + }); + + testWidgets('stationary pan drops carried horizontal momentum', ( + tester, + ) async { + await withMacOSPlatform(() async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final singlePointer = TestPointer(112, PointerDeviceKind.trackpad); + await sendHorizontalTrackpadFling( + tester, + singlePointer, + center, + Duration.zero, + (first: 100, second: 200, third: 300), + ); + output.clear(); + await tester.pump(const Duration(milliseconds: 100)); + final singleFlingReportCount = sgrMouseCodes(output).length; + await tester.sendEventToBinding( + singlePointer.scrollInertiaCancel( + timeStamp: const Duration(milliseconds: 150), + ), + ); + await tester.pump(); + + await sendHorizontalTrackpadFling( + tester, + TestPointer(113, PointerDeviceKind.trackpad), + center, + const Duration(milliseconds: 200), + (first: 30, second: 60, third: 100), + ); + await tester.pump(const Duration(milliseconds: 32)); + await sendPausedHorizontalTrackpadFling( + tester, + TestPointer(114, PointerDeviceKind.trackpad), + center, + const Duration(milliseconds: 257), + ); + output.clear(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + sgrMouseCodes(output).length, + lessThanOrEqualTo(singleFlingReportCount + 1), + ); + }); + }); + + testWidgets('restarts after terminal modes change during inertia', ( + tester, + ) async { + await withMacOSPlatform(() async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + await sendHorizontalTrackpadFling( + tester, + TestPointer(119, PointerDeviceKind.trackpad), + center, + Duration.zero, + (first: 100, second: 200, third: 300), + ); + await tester.pump(const Duration(milliseconds: 32)); + + writeUtf8(controller, '\x1b[?1003l\x1b[?1006l\x1b[?1049l'); + await tester.pump(); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + output.clear(); + + await sendHorizontalTrackpadFling( + tester, + TestPointer(120, PointerDeviceKind.trackpad), + center, + const Duration(milliseconds: 100), + (first: 100, second: 200, third: 300), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + sgrMouseCodes(output), + allOf(isNotEmpty, everyElement(equals(66))), + ); + }); + }); + + testWidgets('touch drag emits alternate-scroll key input', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(0, -300)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(decodeOutput(output), contains('\x1b[B')); + }); + + testWidgets('fast touch scrolling keeps one vertical direction', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(0, -60)); + await gesture.moveBy(const Offset(0, -60)); + await gesture.moveBy(const Offset(0, -60)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + final codes = sgrMouseCodes(output); + expect(codes, allOf(isNotEmpty, everyElement(equals(65)))); + }); + + testWidgets('touch scroll stays at its sequence start position', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final terminalView = find.byType(TerminalView); + final center = tester.getCenter(terminalView); + final localStart = center - tester.getTopLeft(terminalView); + final startCell = renderer(tester).metrics.cellAt(localStart); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(-60, -10)); + await gesture.moveBy(const Offset(-60, -10)); + await gesture.moveBy(const Offset(-60, -10)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + final positions = sgrMousePositions(output); + expect( + positions, + allOf( + isNotEmpty, + everyElement(equals((x: startCell.col + 1, y: startCell.row + 1))), + ), + ); + }); + + testWidgets('touch scrolling clears the active selection', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + controller.selectAll(); + await tester.pump(); + + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + final selectionCleared = !controller.hasSelection; + await gesture.up(); + await tester.pump(); + + expect(selectionCleared, isTrue); + }); + + testWidgets('alternate wheel scrolling clears the active selection', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + controller.selectAll(); + await tester.pump(); + + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding( + PointerScrollEvent( + position: center, + scrollDelta: const Offset(0, -16), + ), + ); + await tester.pump(); + + expect(controller.hasSelection, isFalse); + }); + + testWidgets('touch horizontal scroll emits right-wheel reports', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(decodeOutput(output), contains('\x1b[<67;')); + }); + + testWidgets('touch horizontal momentum keeps the fling direction', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(107); + await tester.sendEventToBinding(pointer.down(center)); + await tester.sendEventToBinding( + pointer.move( + center.translate(-30, 0), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + pointer.move( + center.translate(-60, 0), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + pointer.move( + center.translate(-100, 0), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await tester.pump(); + final dragEventCount = output.length; + await tester.sendEventToBinding( + pointer.up(timeStamp: const Duration(milliseconds: 25)), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final momentumCodes = sgrMouseCodesAfter(output, dragEventCount); + expect(momentumCodes, allOf(isNotEmpty, everyElement(equals(67)))); + }); + + testWidgets('touch scrolling focuses the touched split terminal', ( + tester, + ) async { + final controller2 = TerminalController(); + final focusNode = FocusNode(); + final focusNode2 = FocusNode(); + addTearDown(controller2.dispose); + addTearDown(focusNode.dispose); + addTearDown(focusNode2.dispose); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + writeUtf8(controller2, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 480, + child: Column( + children: [ + Expanded( + child: TerminalView( + controller: controller, + focusNode: focusNode, + autofocus: true, + ), + ), + Expanded( + child: TerminalView( + controller: controller2, + focusNode: focusNode2, + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final bottom = find.byType(TerminalView).last; + final gesture = await tester.startGesture(tester.getCenter(bottom)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(focusNode.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); + }); + + testWidgets('touch sequence remains owned by its starting split', ( + tester, + ) async { + final controller2 = TerminalController(); + addTearDown(controller2.dispose); + writeUtf8(controller, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + writeUtf8(controller2, '\x1b[?1049h\x1b[?1003h\x1b[?1006h'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 480, + child: Row( + children: [ + Expanded(child: TerminalView(controller: controller)), + Expanded(child: TerminalView(controller: controller2)), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final firstOutput = []; + final secondOutput = []; + controller.onOutput = firstOutput.add; + controller2.onOutput = secondOutput.add; + final terminals = find.byType(TerminalView); + final start = tester.getCenter(terminals.first); + final other = tester.getCenter(terminals.last); + final pointer = TestPointer(116); + + await tester.sendEventToBinding(pointer.down(start)); + await tester.sendEventToBinding( + pointer.move( + start.translate(0, -100), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + pointer.move( + other.translate(0, -100), + timeStamp: const Duration(milliseconds: 32), + ), + ); + await tester.pump(); + final routing = ( + startReceived: firstOutput.isNotEmpty, + otherReceived: secondOutput.isNotEmpty, + ); + await tester.sendEventToBinding( + pointer.up(timeStamp: const Duration(milliseconds: 48)), + ); + + expect(routing, (startReceived: true, otherReceived: false)); + }); + + testWidgets('touch drag starts while prior momentum is active', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final firstPointer = TestPointer(105); + await tester.sendEventToBinding(firstPointer.down(center)); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -100), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -200), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -300), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await tester.sendEventToBinding( + firstPointer.up(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + final secondPointer = TestPointer(106); + await tester.sendEventToBinding( + secondPointer.down( + center, + timeStamp: const Duration(milliseconds: 57), + ), + ); + output.clear(); + await tester.sendEventToBinding( + secondPointer.move( + center.translate(0, -100), + timeStamp: const Duration(milliseconds: 73), + ), + ); + await tester.pump(); + final restarted = output.isNotEmpty; + await tester.sendEventToBinding( + secondPointer.up(timeStamp: const Duration(milliseconds: 89)), + ); + + expect(restarted, isTrue); + }); + + testWidgets('touch contact immediately holds active momentum', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); + + final output = []; + controller.onOutput = output.add; + final center = tester.getCenter(find.byType(TerminalView)); + final firstPointer = TestPointer(117); + await tester.sendEventToBinding(firstPointer.down(center)); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -100), + timeStamp: const Duration(milliseconds: 8), + ), + ); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -200), + timeStamp: const Duration(milliseconds: 16), + ), + ); + await tester.sendEventToBinding( + firstPointer.move( + center.translate(0, -300), + timeStamp: const Duration(milliseconds: 24), + ), + ); + await tester.sendEventToBinding( + firstPointer.up(timeStamp: const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 32)); + + final secondPointer = TestPointer(118); + await tester.sendEventToBinding( + secondPointer.down( + center, + timeStamp: const Duration(milliseconds: 57), + ), + ); + output.clear(); + await tester.pump(const Duration(milliseconds: 100)); + final held = output.isEmpty; + await tester.sendEventToBinding( + secondPointer.up(timeStamp: const Duration(milliseconds: 157)), + ); + + expect(held, isTrue); + }); + + testWidgets('untracked trackpad pan remains available to scrolling', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + + final pointer = TestPointer(101, PointerDeviceKind.trackpad); + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, 100)), + ); + await tester.pumpAndSettle(); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + expect(fixture.scrollController.position.pixels, isNot(initialPixels)); + }); + + testWidgets('Shift leaves trackpad pan available to scrolling', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + writeUtf8(controller, '\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + + final output = []; + controller.onOutput = output.add; + final pointer = TestPointer(102, PointerDeviceKind.trackpad); + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, 100)), + ); + await tester.pumpAndSettle(); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(output, isEmpty); + expect(fixture.scrollController.position.pixels, isNot(initialPixels)); + }); + + testWidgets('virtual Shift leaves trackpad pan available to scrolling', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final initialPixels = fixture.scrollController.position.pixels; + writeUtf8(controller, '\x1b[?1003h\x1b[?1006h'); + await tester.pump(); + controller.toggleMod(const Mods.shift()); + + final output = []; + controller.onOutput = output.add; + final pointer = TestPointer(103, PointerDeviceKind.trackpad); + final center = tester.getCenter(find.byType(TerminalView)); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, 100)), + ); + await tester.pumpAndSettle(); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + expect(output, isEmpty); + expect(fixture.scrollController.position.pixels, isNot(initialPixels)); + }); + + testWidgets('pixel offset maps to terminal row after existing scroll', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + final targetRow = fixture.terminal.scrollbackRows ~/ 2; fixture.terminal.scrollToBottom(); fixture.scrollController.jumpTo(targetRow * fixture.cellHeight); @@ -1356,6 +2611,30 @@ void main() { expect(fixture.terminal.scrollbar.offset, targetRow); }); + testWidgets('controller viewport commands synchronize Flutter scroll', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + + controller.scrollToTop(); + await tester.pump(); + + expect(fixture.terminal.scrollbar.offset, 0); + expect(fixture.scrollController.position.pixels, 0); + + controller.scrollToBottom(); + await tester.pump(); + + expect( + fixture.terminal.scrollbar.offset, + fixture.terminal.scrollbackRows, + ); + expect( + fixture.scrollController.position.pixels, + fixture.scrollController.position.maxScrollExtent, + ); + }); + testWidgets('negative pixel offset clamps to top row', (tester) async { final fixture = await pumpScrollableTerminal(tester); @@ -1469,15 +2748,21 @@ void main() { group('virtual mods', () { testWidgets('focus loss clears virtual mods', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), + wrapInApp( + controller: controller, + focusNode: focusNode, + autofocus: true, + ), ); await tester.pump(); controller.toggleMod(const Mods.ctrl()); expect(controller.virtualMods.hasCtrl, isTrue); - controller.unfocus(); + focusNode.unfocus(); await tester.pumpAndSettle(); expect(controller.virtualMods, const Mods.none()); diff --git a/packages/flterm/test/widgets/selection_gesture_driver_test.dart b/packages/flterm/test/widgets/selection_gesture_driver_test.dart deleted file mode 100644 index 13b6eb60..00000000 --- a/packages/flterm/test/widgets/selection_gesture_driver_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -@Tags(['ffi']) -library; - -import 'dart:typed_data'; - -import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/widgets/selection_gesture_driver.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart'; - -void main() { - group('SelectionGestureDriver', () { - late Terminal terminal; - late SelectionGestureDriver driver; - - setUp(() { - terminal = Terminal(cols: 20, rows: 5); - driver = SelectionGestureDriver(terminal); - }); - - tearDown(() { - driver.dispose(); - terminal.dispose(); - }); - - void writeUtf8(String text) { - terminal.write(Uint8List.fromList(text.codeUnits)); - } - - GridRef refAt({required int col, required int row}) { - return GridRef.at(terminal, Position(row: row, col: col)); - } - - group('drag', () { - test('uses word boundaries from press', () { - writeUtf8('alpha_beta gamma'); - driver.press( - ref: refAt(col: 11, row: 0), - localPosition: const Offset(88, 0), - settings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .word, - doubleClick: .word, - tripleClick: .line, - ), - wordBoundaries: '_', - ), - ); - - final selection = driver.drag( - ref: refAt(col: 6, row: 0), - localPosition: const Offset(48, 0), - rectangle: false, - geometry: const SelectionGestureGeometry( - columns: 20, - cellWidth: 8, - paddingLeft: 0, - screenHeight: 80, - ), - ); - - expect(terminal.formatSelection(selection: selection), 'beta gamma'); - }); - }); - }); -} diff --git a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart deleted file mode 100644 index bf6b9248..00000000 --- a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart +++ /dev/null @@ -1,885 +0,0 @@ -@Tags(['ffi']) -library; - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/links/link_settings.dart'; -import 'package:flterm/src/widgets.dart'; -import 'package:flterm/src/widgets/link_interaction.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' - show - Mods, - MouseTracking, - Position, - Selection, - SelectionGestureBehaviors, - Terminal; - -extension _SelectionEdges on Selection { - Position get _startPoint => start.positionIn(.viewport)!; - - Position get _endPoint => end.positionIn(.viewport)!; - - bool get _forward { - final start = _startPoint; - final end = _endPoint; - return start.row != end.row ? start.row < end.row : start.col <= end.col; - } - - int get startRow => _startPoint.row; - - int get startCol => _forward ? _startPoint.col : _startPoint.col + 1; - - int get endRow => _endPoint.row; - - int get endCol => _forward ? _endPoint.col + 1 : _endPoint.col; - - TerminalSelectionShape get mode { - return rectangle - ? TerminalSelectionShape.rectangle - : TerminalSelectionShape.normal; - } -} - -void main() { - group('TerminalGestureDetector', () { - const defaultMetrics = CellMetrics( - cellWidth: 8, - cellHeight: 16, - baseline: 12, - ); - final enableNormalMouse = Uint8List.fromList(utf8.encode('\x1b[?1000h')); - final enableX10Mouse = Uint8List.fromList(utf8.encode('\x1b[?9h')); - - TerminalViewBinding bindingFor(TerminalController controller) { - return controller as TerminalViewBinding; - } - - Terminal terminalFor(TerminalController controller) { - return bindingFor(controller).terminal; - } - - void writeToTerminal(TerminalController controller, String text) { - terminalFor(controller).write(Uint8List.fromList(utf8.encode(text))); - } - - Widget buildHandler({ - required TerminalController controller, - CellMetrics metrics = defaultMetrics, - TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), - LinkInteraction? links, - ValueChanged? onLinkActivate, - ScrollController? scrollController, - int visibleRows = 24, - }) { - return Directionality( - textDirection: TextDirection.ltr, - child: Align( - alignment: Alignment.topLeft, - child: TerminalGestureDetector( - binding: controller as TerminalViewBinding, - metrics: metrics, - links: links ?? LinkInteraction(), - onLinkActivate: onLinkActivate, - settings: gestureSettings, - scrollController: scrollController, - visibleRows: visibleRows, - child: const SizedBox(width: 640, height: 384), - ), - ), - ); - } - - LinkInteraction linkInteractionFor(TerminalController controller) { - final links = LinkInteraction(); - links.update( - context: LinkContext( - terminal: terminalFor(controller), - rows: 24, - cols: 80, - cwd: null, - ), - settings: LinkSettings(modifier: .none, onActivate: (_) {}), - idleStyle: const HyperlinkStyle(), - ); - return links; - } - - void enableMouseTracking( - TerminalController controller, { - MouseTracking mode = .normal, - }) { - final seq = switch (mode) { - .normal => enableNormalMouse, - .x10 => enableX10Mouse, - _ => enableNormalMouse, - }; - final viewBinding = bindingFor(controller); - viewBinding.terminal.write(seq); - viewBinding.handleResize( - cols: 80, - rows: 24, - metrics: defaultMetrics, - padding: EdgeInsets.zero, - devicePixelRatio: 1.0, - ); - } - - Future mouseDown( - WidgetTester tester, - Offset pos, { - int buttons = kPrimaryButton, - }) { - return tester.startGesture(pos, kind: .mouse, buttons: buttons); - } - - late TerminalController controller; - - setUp(() => controller = TerminalController()); - - tearDown(() => controller.dispose()); - - Future tapMouse( - WidgetTester tester, - Offset position, { - int count = 1, - }) async { - for (var i = 0; i < count; i++) { - final gesture = await mouseDown(tester, position); - await gesture.up(); - } - } - - testWidgets('tap leaves selection empty', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(40, 16)); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('tap activates a link without starting selection', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); - - await tapMouse(tester, const Offset(8, 0)); - - expect(links, hasLength(1)); - expect(links.single.text, 'https://example.test'); - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('tap up activates the press candidate after invalidation', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - linkInteraction.invalidateContent(); - await gesture.up(); - - expect(links.single.text, 'https://example.test'); - }); - - testWidgets('drag cancels claimed link tap', (tester) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await tester.pump(kPressTimeout); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - expect(links, isEmpty); - }); - - testWidgets('mouse tracking takes priority over link activation', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - enableMouseTracking(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); - - await tapMouse(tester, const Offset(8, 0)); - - expect(links, isEmpty); - }); - - testWidgets('drag creates selection with correct cells', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(40, 16)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 1); - expect(selection.endRow, 1); - expect(selection.endCol, 5); - expect(selection.mode, TerminalSelectionShape.normal); - }); - - testWidgets('mouse up ends selection drag', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.endRow, 2); - }); - - testWidgets('drag to same cell does not change selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(40, 16)); - final selAfterFirst = terminalFor(controller).selection; - - await gesture.moveTo(const Offset(41, 17)); - final selAfterSecond = terminalFor(controller).selection; - - expect(selAfterFirst, selAfterSecond); - - await gesture.up(); - }); - - testWidgets('double click selects word', (tester) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(8, 0), count: 2); - - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 0); - expect(selection.endCol, 5); - }); - - testWidgets('double click on second word selects it', (tester) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(56, 0), count: 2); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 6); - expect(selection.endCol, 11); - }); - - testWidgets('double click uses configured word boundaries', (tester) async { - final boundaryController = TerminalController(); - addTearDown(boundaryController.dispose); - writeToTerminal(boundaryController, 'hello_world'); - - await tester.pumpWidget( - buildHandler( - controller: boundaryController, - gestureSettings: const TerminalGestureSettings(wordBoundaries: '_'), - ), - ); - - await tapMouse(tester, const Offset(64, 0), count: 2); - - final selection = terminalFor(boundaryController).selection!; - expect(selection.startCol, 6); - expect(selection.endCol, 11); - }); - - testWidgets('triple click selects line content only', (tester) async { - writeToTerminal(controller, 'Hello'); - - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(40, 0), count: 3); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 5); - }); - - testWidgets('triple click on wrapped line selects full terminal line', ( - tester, - ) async { - final narrowController = TerminalController( - config: const TerminalConfig(cols: 10, rows: 5), - ); - addTearDown(narrowController.dispose); - - writeToTerminal(narrowController, 'ABCDEFGHIJKLMNO'); - - await tester.pumpWidget(buildHandler(controller: narrowController)); - - await tapMouse(tester, const Offset(8, 16), count: 3); - - final selection = terminalFor(narrowController).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 0); - expect(selection.endRow, 1); - expect(selection.endCol, 5); - }); - - testWidgets('triple click with fullRow mode selects entire row width', ( - tester, - ) async { - final wideController = TerminalController( - config: const TerminalConfig(cols: 20, rows: 5), - ); - addTearDown(wideController.dispose); - - writeToTerminal(wideController, 'Hello'); - - await tester.pumpWidget( - buildHandler( - controller: wideController, - gestureSettings: const TerminalGestureSettings(lineSelectMode: .full), - ), - ); - - await tapMouse(tester, const Offset(8, 0), count: 3); - - final selection = terminalFor(wideController).selection!; - expect(selection.endCol, 20); - }); - - testWidgets('tap counting resets on distant clicks', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(40, 16)); - await tapMouse(tester, const Offset(200, 200)); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('touch long press starts normal selection by default', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await tester.startGesture(const Offset(40, 16)); - - await tester.pump(const Duration(milliseconds: 550)); - - expect(terminalFor(controller).selection, isNull); - - await gesture.moveTo(const Offset(80, 32)); - final sel = terminalFor(controller).selection!; - expect(sel.mode, TerminalSelectionShape.normal); - - await gesture.up(); - }); - - testWidgets('touch move cancels long press if distance exceeds threshold', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await tester.startGesture(const Offset(40, 16)); - await gesture.moveTo(const Offset(80, 16)); - - await tester.pump(const Duration(milliseconds: 550)); - - await gesture.moveTo(const Offset(120, 16)); - expect(terminalFor(controller).selection, isNull); - - await gesture.up(); - }); - - testWidgets('new click clears existing selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - expect(terminalFor(controller).selection, isNotNull); - - final gesture2 = await mouseDown(tester, const Offset(40, 16)); - await gesture2.up(); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('click without existing selection keeps selection null', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(40, 16)); - await gesture.up(); - - expect(terminalFor(controller).selection, isNull); - }); - - group('gesture settings', () { - testWidgets('dragSelection false prevents drag selection', ( - tester, - ) async { - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - dragSelection: false, - ), - ), - ); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('longPressSelection false cancels press selection', ( - tester, - ) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - longPressSelection: false, - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .line, - doubleClick: .word, - tripleClick: .line, - ), - ), - ), - ); - - final gesture = await tester.startGesture(const Offset(40, 16)); - await tester.pump(const Duration(milliseconds: 550)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('single click uses configured line behavior', (tester) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .line, - doubleClick: .word, - tripleClick: .line, - ), - ), - ), - ); - - await tapMouse(tester, const Offset(8, 0)); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 11); - }); - - testWidgets('double click uses configured line behavior', (tester) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .cell, - doubleClick: .line, - tripleClick: .line, - ), - ), - ), - ); - - await tapMouse(tester, const Offset(8, 0), count: 2); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 11); - }); - - testWidgets('triple click uses configured word behavior', (tester) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .cell, - doubleClick: .line, - tripleClick: .word, - ), - ), - ), - ); - - await tapMouse(tester, const Offset(56, 0), count: 3); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 6); - expect(selection.endCol, 11); - }); - - testWidgets('dragSelection false keeps press selection enabled', ( - tester, - ) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - dragSelection: false, - ), - ), - ); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - expect(terminalFor(controller).selection, isNull); - - await tapMouse(tester, const Offset(8, 0), count: 2); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 5); - }); - - testWidgets('double click cell behavior leaves selection empty', ( - tester, - ) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .cell, - doubleClick: .cell, - tripleClick: .line, - ), - ), - ), - ); - - await tapMouse(tester, const Offset(8, 0), count: 2); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('triple click cell behavior leaves selection empty', ( - tester, - ) async { - writeToTerminal(controller, 'hello world'); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - selectionBehaviors: SelectionGestureBehaviors( - singleClick: .cell, - doubleClick: .word, - tripleClick: .cell, - ), - ), - ), - ); - - await tapMouse(tester, const Offset(8, 0), count: 3); - - expect(terminalFor(controller).selection, isNull); - }); - - testWidgets('longPressSelectionShape block uses block mode', ( - tester, - ) async { - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - longPressSelectionShape: .rectangle, - ), - ), - ); - - final gesture = await tester.startGesture(const Offset(40, 16)); - await tester.pump(const Duration(milliseconds: 550)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.mode, TerminalSelectionShape.rectangle); - }); - - testWidgets( - 'disabled selection affordances still allow mouse tracking output', - (tester) async { - enableMouseTracking(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - gestureSettings: const TerminalGestureSettings( - dragSelection: false, - longPressSelection: false, - selectAllShortcut: false, - ), - ), - ); - - final events = []; - controller.onOutput = events.add; - - final gesture = await mouseDown(tester, const Offset(24, 16)); - await gesture.up(); - - expect(events, isNotEmpty); - }, - ); - }); - - group('virtual mods', () { - testWidgets('virtual alt triggers block selection on drag', ( - tester, - ) async { - controller.toggleMod(const Mods.alt()); - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.mode, TerminalSelectionShape.rectangle); - }); - - testWidgets('virtual alt triggers block selection on long press', ( - tester, - ) async { - controller.toggleMod(const Mods.alt()); - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await tester.startGesture(const Offset(40, 16)); - await tester.pump(const Duration(milliseconds: 550)); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.mode, TerminalSelectionShape.rectangle); - }); - - testWidgets('toggling alt mid-drag switches selection mode', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(80, 32)); - expect( - terminalFor(controller).selection!.mode, - TerminalSelectionShape.normal, - ); - - controller.toggleMod(const Mods.alt()); - await gesture.moveTo(const Offset(80, 48)); - expect( - terminalFor(controller).selection!.mode, - TerminalSelectionShape.rectangle, - ); - - controller.toggleMod(const Mods.alt()); - await gesture.moveTo(const Offset(80, 64)); - expect( - terminalFor(controller).selection!.mode, - TerminalSelectionShape.normal, - ); - - await gesture.up(); - }); - - testWidgets('virtual shift bypasses mouse tracking', (tester) async { - controller.toggleMod(const Mods.shift()); - enableMouseTracking(controller); - - final events = []; - controller.onOutput = events.add; - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 16)); - await gesture.up(); - - expect(events, isEmpty); - }); - }); - - group('wide character selection snapping', () { - setUp(() { - terminalFor(controller).write(Uint8List.fromList(utf8.encode('AB日CD'))); - }); - - testWidgets('drag from spacer snaps anchor inclusive', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 0)); - await gesture.moveTo(const Offset(40, 0)); - await gesture.up(); - - expect(controller.selectedText(), '日C'); - }); - - testWidgets('drag ending on wide char snaps end exclusive', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(24, 0)); - expect(controller.selectedText(), 'AB日'); - - await gesture.moveTo(const Offset(16, 0)); - expect(controller.selectedText(), 'AB'); - - await gesture.up(); - }); - - testWidgets('leftward drag from spacer snaps anchor exclusive', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 0)); - await gesture.moveTo(Offset.zero); - await gesture.up(); - - expect(controller.selectedText(), 'AB日'); - }); - - testWidgets('narrow cells pass through unaffected', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(8, 0)); - await gesture.up(); - - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 1); - }); - - testWidgets('double click on spacer leaves selection empty', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); - - await tapMouse(tester, const Offset(24, 0), count: 2); - - expect(terminalFor(controller).selection, isNull); - }); - }); - - group('mouse tracking', () { - testWidgets('click fires press and release when mode is normal', ( - tester, - ) async { - enableMouseTracking(controller); - - final events = []; - controller.onOutput = events.add; - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 16)); - await gesture.up(); - - expect(events.length, 2); - }); - - testWidgets('click fires press only when mode is x10', (tester) async { - enableMouseTracking(controller, mode: .x10); - - final events = []; - controller.onOutput = events.add; - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 16)); - await gesture.up(); - - expect(events.length, 1); - }); - - testWidgets('no events when mode is none', (tester) async { - final events = []; - controller.onOutput = events.add; - - await tester.pumpWidget(buildHandler(controller: controller)); - - final gesture = await mouseDown(tester, const Offset(24, 16)); - await gesture.up(); - - expect(events, isEmpty); - }); - }); - }); -} diff --git a/packages/flterm/test/widgets/terminal_view_binding_test.dart b/packages/flterm/test/widgets/terminal_view_binding_test.dart deleted file mode 100644 index 8e9a725f..00000000 --- a/packages/flterm/test/widgets/terminal_view_binding_test.dart +++ /dev/null @@ -1,466 +0,0 @@ -@Tags(['ffi']) -library; - -import 'dart:convert'; - -import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/widgets/terminal_controller_impl.dart'; -import 'package:flterm/src/widgets/terminal_view_binding.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' hide KeyEvent; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('TerminalViewBinding', () { - late TerminalViewBinding binding; - late TerminalControllerImpl controller; - - setUp(() { - controller = TerminalControllerImpl(); - binding = controller as TerminalViewBinding; - }); - - tearDown(() => controller.dispose()); - - void writeUtf8(Terminal terminal, String text) { - terminal.write(Uint8List.fromList(utf8.encode(text))); - } - - void writeNumberedLines(TerminalControllerImpl target, int count) { - for (var i = 0; i < count; i++) { - writeUtf8(target.terminal, 'line $i\r\n'); - } - } - - group('attach and detach', () { - test('detaches after attach', () { - final focusNode = FocusNode(); - final scrollController = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(scrollController.dispose); - - binding.attach(focusNode, scrollController, viewId: 0); - - expect(binding.detach, returnsNormally); - }); - - test('re-attach replaces previous focus node', () { - final node1 = FocusNode(); - final node2 = FocusNode(); - final scrollController1 = ScrollController(); - final scrollController2 = ScrollController(); - addTearDown(node1.dispose); - addTearDown(node2.dispose); - addTearDown(scrollController1.dispose); - addTearDown(scrollController2.dispose); - - binding.attach(node1, scrollController1, viewId: 0); - - expect( - () => binding.attach(node2, scrollController2, viewId: 0), - returnsNormally, - ); - }); - }); - - group('handleResize', () { - test('fires onResize callback with correct dimensions', () { - int? reportedCols; - int? reportedRows; - controller.onResize = (cols, rows) { - reportedCols = cols; - reportedRows = rows; - }; - - binding.handleResize( - cols: 120, - rows: 40, - metrics: const CellMetrics( - cellWidth: 8, - cellHeight: 16, - baseline: 12, - ), - padding: EdgeInsets.zero, - devicePixelRatio: 1.0, - ); - - expect(reportedCols, 120); - expect(reportedRows, 40); - }); - }); - - group('handleScroll', () { - test('emits cursor key sequences on alternate screen', () { - writeUtf8(controller.terminal, '\x1b[?1049h'); - final output = []; - controller.onOutput = output.add; - - binding.handleScroll(-3); - - expect(output, hasLength(1)); - expect(output.first.length, greaterThan(0)); - }); - - test('emits no output on primary screen', () { - final output = []; - controller.onOutput = output.add; - - binding.handleScroll(-3); - - expect(output, isEmpty); - }); - - test('emits no output for zero lines', () { - writeUtf8(controller.terminal, '\x1b[?1049h'); - final output = []; - controller.onOutput = output.add; - - binding.handleScroll(0); - - expect(output, isEmpty); - }); - }); - - group('selection drag', () { - test('updates terminal selection', () { - final custom = TerminalControllerImpl( - config: const TerminalConfig(cols: 20, rows: 5), - ); - addTearDown(custom.dispose); - final customBinding = custom as TerminalViewBinding; - - custom.terminal.write(Uint8List.fromList(utf8.encode('AB日CD'))); - customBinding.handleResize( - cols: 20, - rows: 5, - metrics: const CellMetrics( - cellWidth: 8, - cellHeight: 16, - baseline: 12, - ), - padding: EdgeInsets.zero, - devicePixelRatio: 1.0, - ); - - customBinding.handleSelectionPress( - cell: const Position(row: 0, col: 2), - localPosition: const Offset(16, 0), - settings: const TerminalGestureSettings(), - ); - customBinding.updateSelectionDrag( - cell: const Position(row: 0, col: 4), - localPosition: const Offset(32, 0), - rectangle: false, - ); - - expect(custom.selectedText(), '日'); - }); - }); - - group('handleKeyEvent', () { - test('returns handled and emits output for printable key', () { - final output = []; - controller.onOutput = output.add; - - final result = binding.handleKeyEvent( - const KeyDownEvent( - physicalKey: PhysicalKeyboardKey.keyA, - logicalKey: LogicalKeyboardKey.keyA, - character: 'a', - timeStamp: Duration.zero, - ), - ); - - expect(result, KeyEventResult.handled); - expect(output, isNotEmpty); - }); - - test('returns ignored for key release', () { - final result = binding.handleKeyEvent( - const KeyUpEvent( - physicalKey: PhysicalKeyboardKey.keyA, - logicalKey: LogicalKeyboardKey.keyA, - timeStamp: Duration.zero, - ), - ); - - expect(result, KeyEventResult.ignored); - }); - - test('clears selection on typing when enabled', () { - controller.selectRange( - start: const Position(row: 0, col: 0), - end: const Position(row: 0, col: 4), - ); - controller.onOutput = (_) {}; - - binding.handleKeyEvent( - const KeyDownEvent( - physicalKey: PhysicalKeyboardKey.keyA, - logicalKey: LogicalKeyboardKey.keyA, - character: 'a', - timeStamp: Duration.zero, - ), - ); - - expect(controller.hasSelection, isFalse); - }); - - test('scrolls to bottom on input', () { - final custom = TerminalControllerImpl( - config: const TerminalConfig(cols: 20, rows: 3), - ); - addTearDown(custom.dispose); - final sc = ScrollController(); - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - addTearDown(sc.dispose); - final customBinding = custom as TerminalViewBinding; - customBinding.attach(focusNode, sc, viewId: 0); - - writeNumberedLines(custom, 10); - custom.terminal.scrollViewport(-5); - expect( - custom.terminal.scrollbar.offset, - lessThan(custom.scrollbackRows), - ); - - custom.onOutput = (_) {}; - customBinding.handleKeyEvent( - const KeyDownEvent( - physicalKey: PhysicalKeyboardKey.keyA, - logicalKey: LogicalKeyboardKey.keyA, - character: 'a', - timeStamp: Duration.zero, - ), - ); - - expect(custom.terminal.scrollbar.offset, custom.scrollbackRows); - }); - }); - - group('scrollToBottom', () { - test('restores viewport to bottom after scrolling up', () { - final custom = TerminalControllerImpl( - config: const TerminalConfig(cols: 20, rows: 3), - ); - addTearDown(custom.dispose); - - writeNumberedLines(custom, 10); - final bottomOffset = custom.terminal.scrollbar.offset; - - custom.terminal.scrollViewport(-5); - expect(custom.terminal.scrollbar.offset, isNot(bottomOffset)); - - custom.scrollToBottom(); - - expect(custom.terminal.scrollbar.offset, bottomOffset); - }); - }); - - group('handleMouseEvent', () { - test('emits encoded output when tracking is enabled', () { - writeUtf8(controller.terminal, '\x1b[?1000h'); - binding.handleResize( - cols: 80, - rows: 24, - metrics: const CellMetrics( - cellWidth: 8, - cellHeight: 16, - baseline: 12, - ), - padding: EdgeInsets.zero, - devicePixelRatio: 1.0, - ); - - final output = []; - controller.onOutput = output.add; - - binding.handleMouseEvent(( - action: .press, - button: .left, - pixelX: 10.0, - pixelY: 10.0, - )); - - expect(output, isNotEmpty); - }); - - test('does not emit when tracking is off', () { - final output = []; - controller.onOutput = output.add; - - binding.handleMouseEvent(( - action: .press, - button: .left, - pixelX: 10.0, - pixelY: 10.0, - )); - - expect(output, isEmpty); - }); - - test('scales pixel coordinates by devicePixelRatio', () { - writeUtf8(controller.terminal, '\x1b[?1000h'); - binding.handleResize( - cols: 80, - rows: 24, - metrics: const CellMetrics( - cellWidth: 8, - cellHeight: 16, - baseline: 12, - ), - padding: EdgeInsets.zero, - devicePixelRatio: 2.0, - ); - - final output = []; - controller.onOutput = output.add; - - binding.handleMouseEvent(( - action: .press, - button: .left, - pixelX: 8.0, - pixelY: 16.0, - )); - - expect(output, hasLength(1)); - expect(output.single, [ - 0x1b, - 0x5b, - 0x4d, - ' '.codeUnitAt(0), - '!'.codeUnitAt(0) + 1, - '!'.codeUnitAt(0) + 1, - ]); - }); - }); - - group('mouseTracking', () { - test('reflects mode changes', () { - expect(binding.mouseTracking, MouseTracking.none); - - writeUtf8(controller.terminal, '\x1b[?1000h'); - - expect(binding.mouseTracking, MouseTracking.normal); - }); - }); - - group('cursorBlinks', () { - test('false without focus', () { - expect(binding.cursorBlinks, isFalse); - }); - - test('stays false without a widget focus context', () { - final focusNode = FocusNode(); - final sc = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(sc.dispose); - binding.attach(focusNode, sc, viewId: 0); - - expect(binding.cursorBlinks, isFalse); - }); - - testWidgets('uses libghostty viewport-active state on primary screen', ( - tester, - ) async { - controller.dispose(); - controller = TerminalControllerImpl( - config: const TerminalConfig(cols: 20, rows: 2), - ); - binding = controller as TerminalViewBinding; - final focusNode = FocusNode(); - final sc = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(sc.dispose); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Focus(focusNode: focusNode, child: const SizedBox()), - ), - ); - binding.attach(focusNode, sc, viewId: 0); - focusNode.requestFocus(); - await tester.pump(); - - writeNumberedLines(controller, 10); - controller.terminal.scrollViewport(-1); - - expect(controller.hasFocus, isTrue); - expect(controller.scrollbackRows, greaterThan(0)); - expect(controller.terminal.isViewportActive, isFalse); - expect(binding.cursorBlinks, isFalse); - }); - - testWidgets('respects cursorBlink config before terminal changes', ( - tester, - ) async { - controller.dispose(); - controller = TerminalControllerImpl( - config: const TerminalConfig(cursorBlink: false), - ); - binding = controller as TerminalViewBinding; - final focusNode = FocusNode(); - final sc = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(sc.dispose); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Focus(focusNode: focusNode, child: const SizedBox()), - ), - ); - binding.attach(focusNode, sc, viewId: 0); - focusNode.requestFocus(); - await tester.pump(); - - expect(controller.hasFocus, isTrue); - expect(binding.cursorBlinks, isFalse); - }); - }); - - group('paste', () { - test('scrolls to bottom on primary screen', () { - final custom = TerminalControllerImpl( - config: const TerminalConfig(cols: 20, rows: 3), - ); - addTearDown(custom.dispose); - - writeNumberedLines(custom, 10); - custom.terminal.scrollViewport(-5); - expect( - custom.terminal.scrollbar.offset, - lessThan(custom.scrollbackRows), - ); - - custom.onOutput = (_) {}; - custom.paste('hello'); - - expect(custom.terminal.scrollbar.offset, custom.scrollbackRows); - }); - }); - - group('primary screen restore', () { - test('re-applies configured modes', () { - final focusNode = FocusNode(); - final sc = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(sc.dispose); - binding.attach(focusNode, sc, viewId: 0); - - writeUtf8(controller.terminal, '\x1b[?1049h'); - writeUtf8(controller.terminal, '\x1b[?12l'); - - writeUtf8(controller.terminal, '\x1b[?1049l'); - - expect( - controller.terminal.modeGet(const TerminalMode.cursorBlinking()), - isTrue, - ); - }); - }); - }); -} diff --git a/packages/flterm/tool/benchmarks/frame/harness.dart b/packages/flterm/tool/benchmarks/frame/harness.dart index ef968f6c..6e23571b 100644 --- a/packages/flterm/tool/benchmarks/frame/harness.dart +++ b/packages/flterm/tool/benchmarks/frame/harness.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:flterm/src/rendering/terminal_frame_source.dart'; import 'package:flterm/src/rendering/terminal_render_cache.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -48,6 +49,9 @@ final class FrameBenchmarkHarness { cache: TerminalRenderCache(), ), ]; + final frameSources = [ + for (final resource in resources) TerminalFrameSource(resource.terminal), + ]; final retainedAtlases = []; try { await _tester.pumpWidget(const SizedBox.shrink()); @@ -58,7 +62,7 @@ final class FrameBenchmarkHarness { _binding.wrapWithDefaultView( BenchmarkTerminalSurface( key: ValueKey(sample), - terminal: resource.terminal, + frameSource: frameSources[sample], cache: resource.cache, ), ), @@ -80,6 +84,9 @@ final class FrameBenchmarkHarness { for (final handle in retainedAtlases) { handle.release(); } + for (final source in frameSources) { + source.dispose(); + } for (final resource in resources) { resource.cache.dispose(); resource.terminal.dispose(); @@ -99,13 +106,15 @@ final class FrameBenchmarkHarness { List? updates, }) async { final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); + final frameSource = TerminalFrameSource(terminal); final cache = TerminalRenderCache(); addTearDown(terminal.dispose); + addTearDown(frameSource.dispose); addTearDown(cache.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(terminal: terminal, cache: cache), + BenchmarkTerminalSurface(frameSource: frameSource, cache: cache), ); terminal.write(TerminalBenchmarkFixture.fullFrames(count: 1).single); await _tester.pump(); @@ -135,12 +144,14 @@ final class FrameBenchmarkHarness { required List updates, }) async { final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); + final frameSource = TerminalFrameSource(terminal); final cache = TerminalRenderCache(); addTearDown(terminal.dispose); + addTearDown(frameSource.dispose); addTearDown(cache.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(terminal: terminal, cache: cache), + BenchmarkTerminalSurface(frameSource: frameSource, cache: cache), ); final summary = await _capture(() async { diff --git a/packages/flterm/tool/benchmarks/frame/render_environment.dart b/packages/flterm/tool/benchmarks/frame/render_environment.dart index ec045e45..8a1baa2b 100644 --- a/packages/flterm/tool/benchmarks/frame/render_environment.dart +++ b/packages/flterm/tool/benchmarks/frame/render_environment.dart @@ -2,16 +2,14 @@ import 'dart:convert' show utf8; import 'package:crypto/crypto.dart' show sha256; import 'package:flterm/src/foundation/cell_metrics.dart'; -import 'package:flterm/src/foundation/terminal_render_observer.dart'; import 'package:flterm/src/foundation/terminal_theme.dart'; import 'package:flterm/src/rendering/atlas/atlas_config.dart'; +import 'package:flterm/src/rendering/terminal_frame_source.dart'; import 'package:flterm/src/rendering/terminal_render_cache.dart'; import 'package:flterm/src/rendering/terminal_renderer.dart'; import 'package:flutter/rendering.dart' show ViewportOffset; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:libghostty/libghostty.dart' show Terminal; - import '../protocol.dart'; const _metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); @@ -73,12 +71,12 @@ String benchmarkFontDigest({ /// Fixed terminal surface shared by every rendering workload. final class BenchmarkTerminalSurface extends StatelessWidget { - final Terminal terminal; + final TerminalFrameSource frameSource; final TerminalRenderCache cache; const BenchmarkTerminalSurface({ super.key, - required this.terminal, + required this.frameSource, required this.cache, }); @@ -92,12 +90,21 @@ final class BenchmarkTerminalSurface extends StatelessWidget { width: benchmarkSurfaceSize.width, height: benchmarkSurfaceSize.height, child: TerminalRenderer( - terminal: terminal, + frameSource: frameSource, theme: _theme, metrics: _metrics, offset: ViewportOffset.zero(), - renderObserver: const _FocusedRenderObserver(), + focused: true, renderCache: cache, + onGeometryChanged: (geometry) => frameSource.terminal.resize( + cols: geometry.cols, + rows: geometry.rows, + cellWidthPx: (geometry.cellWidth * geometry.devicePixelRatio) + .round(), + cellHeightPx: (geometry.cellHeight * geometry.devicePixelRatio) + .round(), + ), + onViewportRowChanged: frameSource.terminal.scrollToRow, ), ), ), @@ -109,16 +116,3 @@ final class BenchmarkTerminalSurface extends StatelessWidget { TerminalAtlasHandle retainBenchmarkAtlas(TerminalRenderCache cache) { return cache.acquireAtlas(_atlasConfig); } - -final class _FocusedRenderObserver implements TerminalRenderObserver { - const _FocusedRenderObserver(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} From 6deaece35324e2e15f9eef22e47ab78edb56e790 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Fri, 14 Aug 2026 21:42:13 +0300 Subject: [PATCH 06/22] refactor(flterm): align internal component names with domain boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine flterm’s internal architecture around clear ownership boundaries and consistent domain-oriented naming. Keep implementation, tests, and benchmarks aligned while preserving terminal behavior and controller/view lifecycle contracts. --- .../src/controller/terminal_controller.dart | 8 +- .../controller/terminal_controller_impl.dart | 479 ++++++------ packages/flterm/lib/src/foundation.dart | 2 +- ...al_geometry.dart => surface_geometry.dart} | 110 +-- ..._input_encoder.dart => input_encoder.dart} | 47 +- ...al_input_event.dart => input_message.dart} | 30 +- ..._detector.dart => interaction_region.dart} | 56 +- ...apter.dart => keyboard_input_adapter.dart} | 37 +- ...r.dart => primitive_gesture_detector.dart} | 4 +- ...andler.dart => scroll_gesture_region.dart} | 407 +++++----- ...ut_client.dart => text_input_session.dart} | 10 +- ..._selection.dart => selection_session.dart} | 301 +++---- .../flterm/lib/src/links/link_resolver.dart | 8 +- ...al_logical_line.dart => logical_line.dart} | 20 +- .../lib/src/links/osc8_link_detector.dart | 6 +- .../lib/src/links/text_link_detector.dart | 8 +- packages/flterm/lib/src/rendering.dart | 2 +- ...inal_render_cache.dart => atlas_pool.dart} | 21 +- ..._frame_builder.dart => frame_builder.dart} | 30 +- ...al_frame_source.dart => frame_source.dart} | 4 +- .../src/rendering/kitty_placement_cache.dart | 2 +- .../flterm/lib/src/rendering/paint_state.dart | 4 +- ..._painter_stack.dart => painter_stack.dart} | 6 +- .../painters/background_painter.dart | 6 +- .../rendering/painters/cursor_painter.dart | 2 +- .../painters/kitty_graphics_painter.dart | 2 +- .../rendering/painters/terminal_painter.dart | 2 +- ...der_pipeline.dart => render_pipeline.dart} | 22 +- .../lib/src/rendering/terminal_renderer.dart | 66 +- ...al_cursor_blink.dart => cursor_blink.dart} | 16 +- ...hortcut_scope.dart => shortcut_scope.dart} | 10 +- .../flterm/lib/src/view/terminal_scope.dart | 25 +- .../src/view/terminal_scroll_controller.dart | 8 +- .../flterm/lib/src/view/terminal_view.dart | 79 +- ...w_attachment.dart => view_attachment.dart} | 71 +- .../controller/terminal_controller_test.dart | 310 ++++---- .../foundation/surface_geometry_test.dart | 132 ++++ .../foundation/terminal_geometry_test.dart | 50 -- ...test.dart => interaction_region_test.dart} | 737 +++++++++--------- ...test.dart => text_input_session_test.dart} | 14 +- ..._line_test.dart => logical_line_test.dart} | 8 +- .../test/links/osc8_link_detector_test.dart | 6 +- .../test/links/text_link_detector_test.dart | 7 +- ...r_cache_test.dart => atlas_pool_test.dart} | 30 +- .../test/rendering/cursor_layer_test.dart | 14 +- .../test/rendering/emoji_golden_test.dart | 14 +- ...lder_test.dart => frame_builder_test.dart} | 29 +- .../test/rendering/frame_source_test.dart | 61 ++ .../kitty_graphics_painter_golden_test.dart | 4 +- .../rendering/kitty_placement_cache_test.dart | 4 +- .../test/rendering/paint_state_test.dart | 10 +- .../painters/cursor_painter_test.dart | 2 +- ...ne_test.dart => render_pipeline_test.dart} | 19 +- .../rendering/row_dirty_tracker_test.dart | 2 +- .../test/rendering/sprites_golden_test.dart | 18 +- .../rendering/terminal_frame_source_test.dart | 47 -- .../terminal_renderer_golden_test.dart | 16 +- .../rendering/terminal_renderer_test.dart | 50 +- .../transparent_background_golden_test.dart | 14 +- .../flterm/test/view/cursor_blink_test.dart | 58 ++ ...ope_test.dart => shortcut_scope_test.dart} | 18 +- .../test/view/terminal_cursor_blink_test.dart | 34 - .../view/terminal_scroll_controller_test.dart | 9 +- .../view/terminal_view_attachment_test.dart | 162 ---- .../flterm/test/view/terminal_view_test.dart | 164 ++-- .../test/view/view_attachment_test.dart | 217 ++++++ packages/flterm/tool/benchmarks/README.md | 2 +- .../flterm/tool/benchmarks/frame/harness.dart | 36 +- .../benchmarks/frame/render_environment.dart | 16 +- 69 files changed, 2228 insertions(+), 1997 deletions(-) rename packages/flterm/lib/src/foundation/{terminal_geometry.dart => surface_geometry.dart} (64%) rename packages/flterm/lib/src/input/{terminal_input_encoder.dart => input_encoder.dart} (74%) rename packages/flterm/lib/src/input/{terminal_input_event.dart => input_message.dart} (92%) rename packages/flterm/lib/src/input/{terminal_gesture_detector.dart => interaction_region.dart} (93%) rename packages/flterm/lib/src/input/{terminal_input_adapter.dart => keyboard_input_adapter.dart} (89%) rename packages/flterm/lib/src/input/{terminal_raw_gesture_detector.dart => primitive_gesture_detector.dart} (97%) rename packages/flterm/lib/src/input/{terminal_scroll_gesture_handler.dart => scroll_gesture_region.dart} (87%) rename packages/flterm/lib/src/input/{terminal_input_client.dart => text_input_session.dart} (97%) rename packages/flterm/lib/src/interaction/{terminal_selection.dart => selection_session.dart} (82%) rename packages/flterm/lib/src/links/{terminal_logical_line.dart => logical_line.dart} (93%) rename packages/flterm/lib/src/rendering/{terminal_render_cache.dart => atlas_pool.dart} (68%) rename packages/flterm/lib/src/rendering/{terminal_frame_builder.dart => frame_builder.dart} (98%) rename packages/flterm/lib/src/rendering/{terminal_frame_source.dart => frame_source.dart} (86%) rename packages/flterm/lib/src/rendering/{terminal_painter_stack.dart => painter_stack.dart} (97%) rename packages/flterm/lib/src/rendering/{terminal_render_pipeline.dart => render_pipeline.dart} (83%) rename packages/flterm/lib/src/view/{terminal_cursor_blink.dart => cursor_blink.dart} (86%) rename packages/flterm/lib/src/view/{terminal_shortcut_scope.dart => shortcut_scope.dart} (94%) rename packages/flterm/lib/src/view/{terminal_view_attachment.dart => view_attachment.dart} (77%) create mode 100644 packages/flterm/test/foundation/surface_geometry_test.dart delete mode 100644 packages/flterm/test/foundation/terminal_geometry_test.dart rename packages/flterm/test/input/{terminal_gesture_detector_test.dart => interaction_region_test.dart} (86%) rename packages/flterm/test/input/{terminal_input_client_test.dart => text_input_session_test.dart} (99%) rename packages/flterm/test/links/{terminal_logical_line_test.dart => logical_line_test.dart} (89%) rename packages/flterm/test/rendering/{terminal_render_cache_test.dart => atlas_pool_test.dart} (60%) rename packages/flterm/test/rendering/{terminal_frame_builder_test.dart => frame_builder_test.dart} (95%) create mode 100644 packages/flterm/test/rendering/frame_source_test.dart rename packages/flterm/test/rendering/{terminal_render_pipeline_test.dart => render_pipeline_test.dart} (87%) delete mode 100644 packages/flterm/test/rendering/terminal_frame_source_test.dart create mode 100644 packages/flterm/test/view/cursor_blink_test.dart rename packages/flterm/test/view/{terminal_shortcut_scope_test.dart => shortcut_scope_test.dart} (94%) delete mode 100644 packages/flterm/test/view/terminal_cursor_blink_test.dart delete mode 100644 packages/flterm/test/view/terminal_view_attachment_test.dart create mode 100644 packages/flterm/test/view/view_attachment_test.dart diff --git a/packages/flterm/lib/src/controller/terminal_controller.dart b/packages/flterm/lib/src/controller/terminal_controller.dart index c9db39a3..378efd74 100644 --- a/packages/flterm/lib/src/controller/terminal_controller.dart +++ b/packages/flterm/lib/src/controller/terminal_controller.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'package:flutter/foundation.dart' hide Key; -import 'package:libghostty/libghostty.dart' hide Listenable, TerminalGeometry; +import 'package:libghostty/libghostty.dart' hide Listenable; import '../foundation.dart'; -import '../input/terminal_input_encoder.dart'; -import '../input/terminal_input_event.dart'; -import '../interaction/terminal_selection.dart'; +import '../input/input_encoder.dart'; +import '../input/input_message.dart'; +import '../interaction/selection_session.dart'; import 'kitty_png_decoder.dart'; part 'terminal_controller_impl.dart'; diff --git a/packages/flterm/lib/src/controller/terminal_controller_impl.dart b/packages/flterm/lib/src/controller/terminal_controller_impl.dart index 0d89f8d5..0ad8fe18 100644 --- a/packages/flterm/lib/src/controller/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/controller/terminal_controller_impl.dart @@ -1,6 +1,6 @@ part of 'terminal_controller.dart'; -typedef _TerminalObservation = ({ +typedef _Observation = ({ TerminalScreen activeScreen, MouseTracking mouseTracking, bool cursorKeyApplication, @@ -11,9 +11,8 @@ typedef _TerminalObservation = ({ /// /// Flutter lifecycle and device events reach this implementation only after /// view-side adapters normalize them into terminal values. Native encoders, -/// Terminal selection resources, geometry commitment, and public callback -/// effects stay -/// within this session boundary. +/// terminal selection resources, geometry commitment, and public callback +/// effects remain inside this session boundary. final class TerminalControllerImpl extends TerminalController { static const _cr = 0x0d; static const _formFeed = 0x0c; @@ -28,14 +27,14 @@ final class TerminalControllerImpl extends TerminalController { final Terminal _terminal; final _viewportChanges = ChangeNotifier(); - late final TerminalInputEncoder _inputEncoder; - late final TerminalSelection _selection; + late final InputEncoder _inputEncoder; + late final SelectionSession _selection; ColorScheme _colorScheme = .dark; - TerminalGeometry? _committedGeometry; + SurfaceGeometry? _committedGeometry; TerminalConfig _config; var _disposed = false; - late _TerminalObservation _observation; + late _Observation _observation; ClipboardWriteCallback? _onClipboardWrite; ValueChanged? _onOutput; VoidCallback? _onPwdChanged; @@ -49,8 +48,8 @@ final class TerminalControllerImpl extends TerminalController { : _config = config, _terminal = Terminal(cols: config.cols, rows: config.rows), super.base() { - _inputEncoder = TerminalInputEncoder(_terminal); - _selection = TerminalSelection(_terminal, notifyListeners); + _inputEncoder = InputEncoder(_terminal); + _selection = SelectionSession(_terminal, notifyListeners); installDefaultKittyPngDecoder(); _wireTerminalCallbacks(); _applyModes(); @@ -65,9 +64,22 @@ final class TerminalControllerImpl extends TerminalController { return _terminal.activeScreen; } - Terminal get terminal { + @override + TerminalConfig get config { _checkNotDisposed(); - return _terminal; + return _config; + } + + @override + set config(TerminalConfig value) { + _checkNotDisposed(); + if (_config == value) return; + _config = value; + _applyModes(); + _applyTerminalOptions(); + _wireTerminalCallbacks(); + _observation = _readObservation(); + notifyListeners(); } bool get cursorBlinking { @@ -75,29 +87,38 @@ final class TerminalControllerImpl extends TerminalController { return _observation.cursorBlinking; } + @override + bool get hasSelection { + _checkNotDisposed(); + return _selection.hasSelection; + } + bool get isDisposed => _disposed; - Listenable get viewportChanges { + @override + MouseTracking get mouseTracking { _checkNotDisposed(); - return _viewportChanges; + return _observation.mouseTracking; } - void setColorScheme(ColorScheme value) { + @override + set onBell(VoidCallback? value) { _checkNotDisposed(); - if (_colorScheme == value) return; - _colorScheme = value; + _terminal.onBell = value; } @override - TerminalConfig get config { + set onClipboardWrite(ClipboardWriteCallback? value) { _checkNotDisposed(); - return _config; + if (identical(_onClipboardWrite, value)) return; + _onClipboardWrite = value; + _terminal.onClipboardWrite = value; } @override - set onBell(VoidCallback? value) { + set onDesktopNotification(ValueChanged? value) { _checkNotDisposed(); - _terminal.onBell = value; + _terminal.onDesktopNotification = value; } @override @@ -107,6 +128,12 @@ final class TerminalControllerImpl extends TerminalController { _terminal.onWritePty = value; } + @override + set onProgressReport(ValueChanged? value) { + _checkNotDisposed(); + _terminal.onProgressReport = value; + } + @override set onPwdChanged(VoidCallback? value) { _checkNotDisposed(); @@ -129,50 +156,6 @@ final class TerminalControllerImpl extends TerminalController { _terminal.onTitleChanged = value; } - @override - set config(TerminalConfig value) { - _checkNotDisposed(); - if (_config == value) return; - _config = value; - _applyModes(); - _applyTerminalOptions(); - _wireTerminalCallbacks(); - _observation = _readObservation(); - notifyListeners(); - } - - @override - bool get hasSelection { - _checkNotDisposed(); - return _selection.hasSelection; - } - - @override - MouseTracking get mouseTracking { - _checkNotDisposed(); - return _observation.mouseTracking; - } - - @override - set onClipboardWrite(ClipboardWriteCallback? value) { - _checkNotDisposed(); - if (identical(_onClipboardWrite, value)) return; - _onClipboardWrite = value; - _terminal.onClipboardWrite = value; - } - - @override - set onDesktopNotification(ValueChanged? value) { - _checkNotDisposed(); - _terminal.onDesktopNotification = value; - } - - @override - set onProgressReport(ValueChanged? value) { - _checkNotDisposed(); - _terminal.onProgressReport = value; - } - @override String get pwd { _checkNotDisposed(); @@ -191,6 +174,11 @@ final class TerminalControllerImpl extends TerminalController { return _terminal.scrollbar; } + Terminal get terminal { + _checkNotDisposed(); + return _terminal; + } + @override String get title { _checkNotDisposed(); @@ -203,15 +191,15 @@ final class TerminalControllerImpl extends TerminalController { return _terminal.totalRows; } - @override - Mods get virtualMods { + Listenable get viewportChanges { _checkNotDisposed(); - return _virtualMods; + return _viewportChanges; } - void cancelSelectionGesture() { + @override + Mods get virtualMods { _checkNotDisposed(); - _selection.cancelGesture(); + return _virtualMods; } Object attachView() { @@ -224,9 +212,9 @@ final class TerminalControllerImpl extends TerminalController { return token; } - void detachView(Object token) { - if (_disposed) return; - if (identical(_viewToken, token)) _viewToken = null; + void cancelSelectionGesture() { + _checkNotDisposed(); + _selection.cancelGesture(); } @override @@ -269,6 +257,11 @@ final class TerminalControllerImpl extends TerminalController { ); } + void detachView(Object token) { + if (_disposed) return; + if (identical(_viewToken, token)) _viewToken = null; + } + @override void dispose() { if (_disposed) return; @@ -282,8 +275,52 @@ final class TerminalControllerImpl extends TerminalController { super.dispose(); } - TerminalKeyDisposition handleTerminalKey( - TerminalKeyInput input, { + void handleFocusChanged({required bool focused}) { + _checkNotDisposed(); + if (!focused) clearVirtualMods(); + + if (_terminal.modeGet(const TerminalMode.focusEvent())) { + final event = focused ? FocusEvent.gained : FocusEvent.lost; + _emitOutput(utf8.encode(event.encode())); + } + } + + void handleMouseEvent(MouseInput input) { + _checkNotDisposed(); + final result = _inputEncoder.encodeMouse( + input, + geometry: _committedGeometry, + ); + if (result.isEmpty) return; + _emitOutput(utf8.encode(result)); + } + + void handleResize(SurfaceMeasurement measurement) { + _checkNotDisposed(); + final geometry = SurfaceGeometry.tryFrom(measurement); + if (geometry == null || geometry == _committedGeometry) return; + + final previous = _committedGeometry; + _commitGeometry(geometry); + if (previous == null || + previous.cols != geometry.cols || + previous.rows != geometry.rows) { + _onResize?.call(geometry.cols, geometry.rows); + } + } + + void handleSelectionPress(SelectionPressInput event) { + _checkNotDisposed(); + _selection.handlePress(event); + } + + void handleSelectionRelease(Position cell) { + _checkNotDisposed(); + _selection.handleRelease(cell); + } + + KeyDisposition handleTerminalKey( + KeyInput input, { required bool routeToTextInput, required bool forwardDeletionToTextInput, }) { @@ -310,72 +347,23 @@ final class TerminalControllerImpl extends TerminalController { return forwardDeletionToTextInput ? .skipRemainingHandlers : .handled; } - void handleMouseEvent(TerminalMouseEvent event) { + void handleTerminalScroll(ScrollInput input) { _checkNotDisposed(); - final result = _inputEncoder.encodeMouse( - event, - geometry: _committedGeometry, - ); - if (result.isEmpty) return; - _emitOutput(utf8.encode(result)); - } + if (input.horizontal == 0 && input.vertical == 0) return; - void handleResize(TerminalResizeEvent event) { - _checkNotDisposed(); - final measurement = TerminalGeometry.tryFrom(event); - if (measurement == null || measurement == _committedGeometry) return; - - final previous = _committedGeometry; - _commitGeometry(measurement); - if (previous == null || - previous.cols != measurement.cols || - previous.rows != measurement.rows) { - _onResize?.call(measurement.cols, measurement.rows); - } - } - - void _commitGeometry(TerminalGeometry geometry) { - final current = _terminal.geometry; - final gridChanged = - current.cols != geometry.cols || current.rows != geometry.rows; - final pixelGeometryChanged = - current.widthPx != geometry.cols * geometry.cellWidthPx || - current.heightPx != geometry.rows * geometry.cellHeightPx; - if (gridChanged || pixelGeometryChanged) { - _terminal.resize( - cols: geometry.cols, - rows: geometry.rows, - cellWidthPx: geometry.cellWidthPx, - cellHeightPx: geometry.cellHeightPx, - ); - } - - _inputEncoder.updateGeometry(geometry); - _selection.updateGeometry(geometry); - - _committedGeometry = geometry; - } - - void handleTerminalScroll(TerminalScrollEvent event) { - _checkNotDisposed(); - if (event.horizontal == 0 && event.vertical == 0) return; - - if (event.reportMouse) { + if (input.reportMouse) { if (_terminal.mouseTracking == .none) return; - final position = (x: event.pixelX, y: event.pixelY); _sendScrollButtons( - event.vertical, + input.vertical, negativeButton: .four, positiveButton: .five, - position: position, - mods: event.mods, + input: input, ); _sendScrollButtons( - event.horizontal, + input.horizontal, negativeButton: .six, positiveButton: .seven, - position: position, - mods: event.mods, + input: input, ); return; } @@ -383,7 +371,7 @@ final class TerminalControllerImpl extends TerminalController { if (_terminal.mouseTracking != .none || _terminal.activeScreen != .alternate || !_terminal.modeGet(const .alternateScroll()) || - event.vertical == 0) { + input.vertical == 0) { return; } @@ -391,19 +379,58 @@ final class TerminalControllerImpl extends TerminalController { final down = _observation.cursorKeyApplication ? _appCursorDown : _cursorDown; - final key = event.vertical < 0 ? up : down; - final count = event.vertical.abs(); + final key = input.vertical < 0 ? up : down; + final count = input.vertical.abs(); _emitOutput(_repeatBytes(key, count)); } - void handleSelectionPress(TerminalSelectionPressEvent event) { + void handleTextCommitted(String text) { _checkNotDisposed(); - _selection.handlePress(event); + if (_virtualMods.isEmpty) { + _emitOutput(utf8.encode(text)); + _onTextInput(); + return; + } + + if (text.length == 1) { + final key = keyFromCodepoint(text.codeUnitAt(0)); + if (key != null) { + sendKey(key); + return; + } + } + + _emitOutput(utf8.encode(text)); + clearVirtualMods(); + _onTextInput(); } - void handleSelectionRelease(Position cell) { + void handleTextCompositionChanged({required bool active}) { _checkNotDisposed(); - _selection.handleRelease(cell); + if (active) _onTextInput(); + } + + void handleTextDeleted(int count) { + _checkNotDisposed(); + if (count <= 0) return; + + var emitted = false; + for (var i = 0; i < count; i++) { + emitted = + _emitKeyPress(.backspace, mods: _virtualMods, clearMods: false) || + emitted; + } + if (!emitted) return; + + clearVirtualMods(); + _onTextInput(); + } + + void handleTextNewline() { + _checkNotDisposed(); + _emitOutput(_crBytes); + clearVirtualMods(); + _onTextInput(); } void invalidateSelection() { @@ -509,6 +536,12 @@ final class TerminalControllerImpl extends TerminalController { clearVirtualMods(); } + void setColorScheme(ColorScheme value) { + _checkNotDisposed(); + if (_colorScheme == value) return; + _colorScheme = value; + } + @override void toggleMod(Mods mod) { _checkNotDisposed(); @@ -516,12 +549,12 @@ final class TerminalControllerImpl extends TerminalController { notifyListeners(); } - void updateSelectionAutoscroll(TerminalSelectionAutoscrollEvent event) { + void updateSelectionAutoscroll(SelectionAutoscrollInput event) { _checkNotDisposed(); _selection.handleAutoscroll(event); } - void updateSelectionDrag(TerminalSelectionDragEvent event) { + void updateSelectionDrag(SelectionDragInput event) { _checkNotDisposed(); _selection.handleDrag(event); } @@ -550,6 +583,32 @@ final class TerminalControllerImpl extends TerminalController { _terminal.defaultCursorBlink = _config.cursorBlink; } + void _checkNotDisposed() { + if (_disposed) throw StateError('TerminalController is disposed.'); + } + + void _commitGeometry(SurfaceGeometry geometry) { + final current = _terminal.geometry; + final gridChanged = + current.cols != geometry.cols || current.rows != geometry.rows; + final pixelGeometryChanged = + current.widthPx != geometry.cols * geometry.cellWidthPx || + current.heightPx != geometry.rows * geometry.cellHeightPx; + if (gridChanged || pixelGeometryChanged) { + _terminal.resize( + cols: geometry.cols, + rows: geometry.rows, + cellWidthPx: geometry.cellWidthPx, + cellHeightPx: geometry.cellHeightPx, + ); + } + + _inputEncoder.updateGeometry(geometry); + _selection.updateGeometry(geometry); + + _committedGeometry = geometry; + } + bool _effectiveCursorBlinking() { return _config.cursorBlink ?? _terminal.modeGet(const .cursorBlinking()); } @@ -569,55 +628,11 @@ final class TerminalControllerImpl extends TerminalController { void _emitOutput(Uint8List bytes) => _onOutput?.call(bytes); - void _sendScrollButtons( - int steps, { - required MouseButton negativeButton, - required MouseButton positiveButton, - required ({double x, double y}) position, - required Mods mods, - }) { - if (steps == 0) return; - final button = steps < 0 ? negativeButton : positiveButton; - final result = _inputEncoder.encodeScrollButton( - button: button, - pixelX: position.x, - pixelY: position.y, - mods: mods, - geometry: _committedGeometry, - ); - if (result.isEmpty) return; - _emitOutput(_repeatBytes(utf8.encode(result), steps.abs())); - } - - Uint8List _repeatBytes(List value, int count) { - final bytes = Uint8List(value.length * count); - for (var i = 0; i < count; i++) { - bytes.setRange(i * value.length, (i + 1) * value.length, value); - } - return bytes; - } - - void handleTextDeleted(int count) { - _checkNotDisposed(); - if (count <= 0) return; - - var emitted = false; - for (var i = 0; i < count; i++) { - emitted = - _emitKeyPress(.backspace, mods: _virtualMods, clearMods: false) || - emitted; - } - if (!emitted) return; - - clearVirtualMods(); - _onTextInput(); - } - - void handleTextNewline() { - _checkNotDisposed(); - _emitOutput(_crBytes); - clearVirtualMods(); - _onTextInput(); + void _handlePwdChanged() { + // The terminal listener publishes the final state after the write ends. + _pwd = _terminal.pwd; + _pwdChanged = true; + _onPwdChanged?.call(); } TerminalSizeInfo _handleSizeQuery() { @@ -638,42 +653,6 @@ final class TerminalControllerImpl extends TerminalController { ); } - void handleTextCommitted(String text) { - _checkNotDisposed(); - if (_virtualMods.isEmpty) { - _emitOutput(utf8.encode(text)); - _onTextInput(); - return; - } - - if (text.length == 1) { - final key = keyFromCodepoint(text.codeUnitAt(0)); - if (key != null) { - sendKey(key); - return; - } - } - - _emitOutput(utf8.encode(text)); - clearVirtualMods(); - _onTextInput(); - } - - void handleTextCompositionChanged({required bool active}) { - _checkNotDisposed(); - if (active) _onTextInput(); - } - - void handleFocusChanged({required bool focused}) { - _checkNotDisposed(); - if (!focused) clearVirtualMods(); - - if (_terminal.modeGet(const TerminalMode.focusEvent())) { - final event = focused ? FocusEvent.gained : FocusEvent.lost; - _emitOutput(utf8.encode(event.encode())); - } - } - void _onTerminalChanged() { if (_disposed) return; final pwdChanged = _pwdChanged; @@ -689,18 +668,26 @@ final class TerminalControllerImpl extends TerminalController { if (pwdChanged || previous != next) notifyListeners(); } - void _handlePwdChanged() { - // The terminal listener publishes the final state after the write ends. - _pwd = _terminal.pwd; - _pwdChanged = true; - _onPwdChanged?.call(); - } - void _onTextInput() { if (_config.selectionClearOnTyping) clearSelection(); _scrollToBottomOnInput(); } + _Observation _readObservation() => ( + activeScreen: _terminal.activeScreen, + mouseTracking: _terminal.mouseTracking, + cursorKeyApplication: _terminal.modeGet(const .cursorKeys()), + cursorBlinking: _effectiveCursorBlinking(), + ); + + Uint8List _repeatBytes(List value, int count) { + final bytes = Uint8List(value.length * count); + for (var i = 0; i < count; i++) { + bytes.setRange(i * value.length, (i + 1) * value.length, value); + } + return bytes; + } + void _scrollToBottomOnInput() { if (_observation.activeScreen == .alternate) return; final policy = _config.scrollToBottom; @@ -713,6 +700,23 @@ final class TerminalControllerImpl extends TerminalController { if (policy == .onOutput || policy == .both) scrollToBottom(); } + void _sendScrollButtons( + int steps, { + required MouseButton negativeButton, + required MouseButton positiveButton, + required ScrollInput input, + }) { + if (steps == 0) return; + final button = steps < 0 ? negativeButton : positiveButton; + final result = _inputEncoder.encodeScrollButton( + input, + button: button, + geometry: _committedGeometry, + ); + if (result.isEmpty) return; + _emitOutput(_repeatBytes(utf8.encode(result), steps.abs())); + } + void _wireTerminalCallbacks() { _terminal.onColorScheme = () => _colorScheme; _terminal.onSize = _handleSizeQuery; @@ -723,15 +727,4 @@ final class TerminalControllerImpl extends TerminalController { ? null : () => .fromList(utf8.encode(enquiry)); } - - _TerminalObservation _readObservation() => ( - activeScreen: _terminal.activeScreen, - mouseTracking: _terminal.mouseTracking, - cursorKeyApplication: _terminal.modeGet(const .cursorKeys()), - cursorBlinking: _effectiveCursorBlinking(), - ); - - void _checkNotDisposed() { - if (_disposed) throw StateError('TerminalController is disposed.'); - } } diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index 35589985..e98c0a9f 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -4,7 +4,7 @@ export 'foundation/color_palette.dart'; export 'foundation/dynamic_color.dart'; export 'foundation/input_types.dart'; export 'foundation/platform_map.dart'; +export 'foundation/surface_geometry.dart'; export 'foundation/terminal_config.dart'; -export 'foundation/terminal_geometry.dart'; export 'foundation/terminal_gesture_settings.dart'; export 'foundation/terminal_theme.dart'; diff --git a/packages/flterm/lib/src/foundation/terminal_geometry.dart b/packages/flterm/lib/src/foundation/surface_geometry.dart similarity index 64% rename from packages/flterm/lib/src/foundation/terminal_geometry.dart rename to packages/flterm/lib/src/foundation/surface_geometry.dart index 2e56d48b..e8eeb668 100644 --- a/packages/flterm/lib/src/foundation/terminal_geometry.dart +++ b/packages/flterm/lib/src/foundation/surface_geometry.dart @@ -8,7 +8,7 @@ import 'package:meta/meta.dart'; /// can therefore commit every non-null instance without repeating validation. @immutable @internal -final class TerminalGeometry { +final class SurfaceGeometry { /// Maximum grid dimension representable by the native terminal geometry. static const _maxGridDimension = 0xffff; @@ -33,7 +33,7 @@ final class TerminalGeometry { final int screenWidth; final int screenHeight; - const TerminalGeometry._({ + const SurfaceGeometry._({ required this.cols, required this.rows, required this.cellWidth, @@ -68,7 +68,7 @@ final class TerminalGeometry { @override bool operator ==(Object other) { - return other is TerminalGeometry && + return other is SurfaceGeometry && other.cols == cols && other.rows == rows && other.cellWidth == cellWidth && @@ -80,29 +80,40 @@ final class TerminalGeometry { other.devicePixelRatio == devicePixelRatio; } - /// Creates a validated measurement from a view resize event. - static TerminalGeometry? tryFrom(TerminalResizeEvent event) { - if (event.cols <= 0 || - event.cols > _maxGridDimension || - event.rows <= 0 || - event.rows > _maxGridDimension || - !_isPositive(event.cellWidth) || - !_isPositive(event.cellHeight) || - !_isNonNegative(event.paddingLeft) || - !_isNonNegative(event.paddingRight) || - !_isNonNegative(event.paddingTop) || - !_isNonNegative(event.paddingBottom) || - !_isPositive(event.devicePixelRatio)) { + /// Creates validated geometry from [measurement]. + /// + /// Returns `null` when a logical value is invalid, a cell rounds to zero + /// physical pixels, or a derived physical value exceeds its C ABI field. + static SurfaceGeometry? tryFrom(SurfaceMeasurement measurement) { + if (measurement.cols <= 0 || + measurement.cols > _maxGridDimension || + measurement.rows <= 0 || + measurement.rows > _maxGridDimension || + !_isPositive(measurement.cellWidth) || + !_isPositive(measurement.cellHeight) || + !_isNonNegative(measurement.paddingLeft) || + !_isNonNegative(measurement.paddingRight) || + !_isNonNegative(measurement.paddingTop) || + !_isNonNegative(measurement.paddingBottom) || + !_isPositive(measurement.devicePixelRatio)) { return null; } - final dpr = event.devicePixelRatio; - final cellWidthPx = _physicalPixels(event.cellWidth, dpr, nonZero: true); - final cellHeightPx = _physicalPixels(event.cellHeight, dpr, nonZero: true); - final paddingLeftPx = _physicalPixels(event.paddingLeft, dpr); - final paddingRightPx = _physicalPixels(event.paddingRight, dpr); - final paddingTopPx = _physicalPixels(event.paddingTop, dpr); - final paddingBottomPx = _physicalPixels(event.paddingBottom, dpr); + final dpr = measurement.devicePixelRatio; + final cellWidthPx = _physicalPixels( + measurement.cellWidth, + dpr, + nonZero: true, + ); + final cellHeightPx = _physicalPixels( + measurement.cellHeight, + dpr, + nonZero: true, + ); + final paddingLeftPx = _physicalPixels(measurement.paddingLeft, dpr); + final paddingRightPx = _physicalPixels(measurement.paddingRight, dpr); + final paddingTopPx = _physicalPixels(measurement.paddingTop, dpr); + final paddingBottomPx = _physicalPixels(measurement.paddingBottom, dpr); if (cellWidthPx == null || cellHeightPx == null || paddingLeftPx == null || @@ -113,23 +124,23 @@ final class TerminalGeometry { } final screenWidth = - event.cols * cellWidthPx + paddingLeftPx + paddingRightPx; + measurement.cols * cellWidthPx + paddingLeftPx + paddingRightPx; final screenHeight = - event.rows * cellHeightPx + paddingTopPx + paddingBottomPx; + measurement.rows * cellHeightPx + paddingTopPx + paddingBottomPx; if (screenWidth > _maxMouseDimension || screenHeight > _maxMouseDimension) { return null; } - return TerminalGeometry._( - cols: event.cols, - rows: event.rows, - cellWidth: event.cellWidth, - cellHeight: event.cellHeight, - paddingLeft: event.paddingLeft, - paddingRight: event.paddingRight, - paddingTop: event.paddingTop, - paddingBottom: event.paddingBottom, - devicePixelRatio: event.devicePixelRatio, + return SurfaceGeometry._( + cols: measurement.cols, + rows: measurement.rows, + cellWidth: measurement.cellWidth, + cellHeight: measurement.cellHeight, + paddingLeft: measurement.paddingLeft, + paddingRight: measurement.paddingRight, + paddingTop: measurement.paddingTop, + paddingBottom: measurement.paddingBottom, + devicePixelRatio: measurement.devicePixelRatio, cellWidthPx: cellWidthPx, cellHeightPx: cellHeightPx, paddingLeftPx: paddingLeftPx, @@ -162,39 +173,40 @@ final class TerminalGeometry { /// A complete terminal surface measurement in logical pixels. /// /// The renderer produces this value; the controller validates and commits it -/// before input and selection consume the resulting [TerminalGeometry]. One -/// event contains the grid, cell metrics, surface padding, and device scale so +/// before input and selection consume the resulting [SurfaceGeometry]. One +/// value contains the grid, cell metrics, surface padding, and device scale so /// no consumer can observe a partially updated measurement. +@internal @immutable -final class TerminalResizeEvent { - /// Number of terminal columns. +final class SurfaceMeasurement { + /// Number of measured terminal columns. final int cols; - /// Number of terminal rows. + /// Number of measured terminal rows. final int rows; - /// Cell width in logical pixels. + /// Logical width of one terminal cell. final double cellWidth; - /// Cell height in logical pixels. + /// Logical height of one terminal cell. final double cellHeight; - /// Logical padding on the left side of the terminal surface. + /// Logical padding before the grid's horizontal origin. final double paddingLeft; - /// Logical padding on the right side of the terminal surface. + /// Logical padding after the grid's horizontal extent. final double paddingRight; - /// Logical padding on the top side of the terminal surface. + /// Logical padding before the grid's vertical origin. final double paddingTop; - /// Logical padding on the bottom side of the terminal surface. + /// Logical padding after the grid's vertical extent. final double paddingBottom; - /// Device-pixel ratio of the hosting Flutter view. + /// Number of physical pixels represented by one logical pixel. final double devicePixelRatio; - const TerminalResizeEvent({ + const SurfaceMeasurement({ required this.cols, required this.rows, required this.cellWidth, @@ -221,7 +233,7 @@ final class TerminalResizeEvent { @override bool operator ==(Object other) { - return other is TerminalResizeEvent && + return other is SurfaceMeasurement && other.cols == cols && other.rows == rows && other.cellWidth == cellWidth && diff --git a/packages/flterm/lib/src/input/terminal_input_encoder.dart b/packages/flterm/lib/src/input/input_encoder.dart similarity index 74% rename from packages/flterm/lib/src/input/terminal_input_encoder.dart rename to packages/flterm/lib/src/input/input_encoder.dart index 5daa64b3..4590fa87 100644 --- a/packages/flterm/lib/src/input/terminal_input_encoder.dart +++ b/packages/flterm/lib/src/input/input_encoder.dart @@ -1,21 +1,21 @@ -import 'package:libghostty/libghostty.dart' hide TerminalGeometry; +import 'package:libghostty/libghostty.dart'; import '../foundation.dart'; -import 'terminal_input_event.dart'; +import 'input_message.dart'; /// Owns the reusable terminal resources that encode normalized input. /// /// It translates renderer-neutral key and pointer values into terminal bytes /// without owning Flutter focus, gesture, or text-input lifecycle. Reusing the /// native events and encoders avoids allocations on input hot paths. -final class TerminalInputEncoder { +final class InputEncoder { final Terminal _terminal; final _keyEvent = KeyEvent(); final _mouseEvent = MouseEvent(); final _keyEncoder = KeyEncoder(); final _mouseEncoder = MouseEncoder(); - TerminalInputEncoder(this._terminal); + InputEncoder(this._terminal); void dispose() { _keyEvent.dispose(); @@ -24,15 +24,15 @@ final class TerminalInputEncoder { _mouseEncoder.dispose(); } - String encodeKey(TerminalKeyInput input) { + String encodeKey(KeyInput input) { _keyEvent ..key = input.key ..mods = input.mods ..action = input.action ..utf8 = input.character + ..composing = input.composing ..consumedMods = input.consumedMods - ..unshiftedCodepoint = input.unshiftedCodepoint - ..composing = input.composing; + ..unshiftedCodepoint = input.unshiftedCodepoint; return _encodeKeyEvent(); } @@ -49,33 +49,28 @@ final class TerminalInputEncoder { return _encodeKeyEvent(); } - String encodeMouse( - TerminalMouseEvent event, { - required TerminalGeometry? geometry, - }) { + String encodeMouse(MouseInput input, {required SurfaceGeometry? geometry}) { _mouseEvent - ..action = event.action - ..mods = event.mods; - _setMousePosition(event.pixelX, event.pixelY, geometry); - if (event.button case final button?) { + ..action = input.action + ..mods = input.mods; + _setMousePosition(input.pixelX, input.pixelY, geometry); + if (input.button case final button?) { _mouseEvent.button = button; } else { _mouseEvent.clearButton(); } _mouseEncoder.sync(_terminal); - _mouseEncoder.setAnyButtonPressed(pressed: event.anyButtonPressed); + _mouseEncoder.setAnyButtonPressed(pressed: input.anyButtonPressed); return _mouseEncoder.encode(_mouseEvent); } - String encodeScrollButton({ + String encodeScrollButton( + ScrollInput input, { required MouseButton button, - required double pixelX, - required double pixelY, - required Mods mods, - required TerminalGeometry? geometry, + required SurfaceGeometry? geometry, }) { - var x = pixelX; - var y = pixelY; + var x = input.pixelX; + var y = input.pixelY; if (geometry != null) { final width = geometry.cols * geometry.cellWidth; final height = geometry.rows * geometry.cellHeight; @@ -86,14 +81,14 @@ final class TerminalInputEncoder { _mouseEvent ..action = .press ..button = button - ..mods = mods; + ..mods = input.mods; _setMousePosition(x, y, geometry); _mouseEncoder.sync(_terminal); _mouseEncoder.setAnyButtonPressed(pressed: false); return _mouseEncoder.encode(_mouseEvent); } - void updateGeometry(TerminalGeometry geometry) { + void updateGeometry(SurfaceGeometry geometry) { _mouseEncoder.setSize( MouseEncoderSize( screenWidth: geometry.screenWidth, @@ -113,7 +108,7 @@ final class TerminalInputEncoder { return _keyEncoder.encode(_keyEvent); } - void _setMousePosition(double x, double y, TerminalGeometry? geometry) { + void _setMousePosition(double x, double y, SurfaceGeometry? geometry) { if (geometry == null) { _mouseEvent.setPosition(x: x, y: y); return; diff --git a/packages/flterm/lib/src/input/terminal_input_event.dart b/packages/flterm/lib/src/input/input_message.dart similarity index 92% rename from packages/flterm/lib/src/input/terminal_input_event.dart rename to packages/flterm/lib/src/input/input_message.dart index 8e5c6875..58197746 100644 --- a/packages/flterm/lib/src/input/terminal_input_event.dart +++ b/packages/flterm/lib/src/input/input_message.dart @@ -1,7 +1,7 @@ import 'package:libghostty/libghostty.dart'; /// The outcome of routing normalized keyboard input. -enum TerminalKeyDisposition { +enum KeyDisposition { /// The terminal did not consume the input. ignored, @@ -13,7 +13,7 @@ enum TerminalKeyDisposition { } /// Keyboard input normalized independently of Flutter key event types. -final class TerminalKeyInput { +final class KeyInput { /// The terminal key action. final KeyAction action; @@ -35,7 +35,7 @@ final class TerminalKeyInput { /// The key's code point without modifiers, or zero when unavailable. final int unshiftedCodepoint; - const TerminalKeyInput({ + const KeyInput({ required this.action, required this.character, required this.composing, @@ -47,7 +47,7 @@ final class TerminalKeyInput { } /// Normalized mouse input for the terminal protocol. -final class TerminalMouseEvent { +final class MouseInput { /// The mouse action being reported. final MouseAction action; @@ -66,7 +66,7 @@ final class TerminalMouseEvent { /// The logical vertical offset from the terminal grid origin. final double pixelY; - const TerminalMouseEvent({ + const MouseInput({ required this.action, required this.anyButtonPressed, required this.button, @@ -77,10 +77,7 @@ final class TerminalMouseEvent { } /// Quantized terminal scroll input captured for one gesture target. -final class TerminalScrollEvent { - /// Signed horizontal cell steps. Negative values scroll left. - final int horizontal; - +final class ScrollInput { /// The modifier state captured when the scroll target was selected. final Mods mods; @@ -90,18 +87,21 @@ final class TerminalScrollEvent { /// The target's logical vertical offset from the terminal grid origin. final double pixelY; - /// Whether to encode mouse reports instead of alternate-scroll keys. - final bool reportMouse; - /// Signed vertical cell steps. Negative values scroll up. final int vertical; - const TerminalScrollEvent({ - required this.horizontal, + /// Signed horizontal cell steps. Negative values scroll left. + final int horizontal; + + /// Whether to encode mouse reports instead of alternate-scroll keys. + final bool reportMouse; + + const ScrollInput({ required this.mods, required this.pixelX, required this.pixelY, - required this.reportMouse, required this.vertical, + required this.horizontal, + required this.reportMouse, }); } diff --git a/packages/flterm/lib/src/input/terminal_gesture_detector.dart b/packages/flterm/lib/src/input/interaction_region.dart similarity index 93% rename from packages/flterm/lib/src/input/terminal_gesture_detector.dart rename to packages/flterm/lib/src/input/interaction_region.dart index 5ce654bc..25b29700 100644 --- a/packages/flterm/lib/src/input/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/input/interaction_region.dart @@ -8,38 +8,38 @@ import 'package:libghostty/libghostty.dart' import 'package:meta/meta.dart'; import '../foundation.dart'; -import '../interaction/terminal_selection.dart'; +import '../interaction/selection_session.dart'; import '../links/link_interaction.dart'; import '../links/link_settings.dart'; -import '../view/terminal_view_attachment.dart'; -import 'terminal_input_event.dart'; -import 'terminal_raw_gesture_detector.dart'; -import 'terminal_scroll_gesture_handler.dart'; +import '../view/view_attachment.dart'; +import 'input_message.dart'; +import 'primitive_gesture_detector.dart'; +import 'scroll_gesture_region.dart'; /// Owns pointer-sequence arbitration for one terminal view. /// /// It keeps mouse reporting, selection, link activation, and cancellation on /// the same pointer identity. Terminal-directed wheel, touch, and trackpad -/// motion is delegated to [TerminalScrollGestureHandler]. All resulting -/// terminal actions cross [TerminalViewAttachment] as normalized values. +/// motion is delegated to [ScrollGestureRegion]. All resulting terminal +/// actions cross [ViewAttachment] as normalized values. /// /// Pointer ownership is decided once per sequence. Modifier changes may alter /// the shape of an active selection, but they do not transfer the sequence to /// link activation or terminal mouse reporting. Cancellation releases every /// owned interaction before another pointer can claim it. @internal -final class TerminalGestureDetector extends StatefulWidget { +final class InteractionRegion extends StatefulWidget { final Widget child; final CellMetrics metrics; final LinkInteraction links; + final ViewAttachment attachment; final ScrollPhysics scrollPhysics; + final ViewInteractionState interaction; final TerminalGestureSettings settings; - final TerminalViewAttachment attachment; final ScrollController? scrollController; - final TerminalInteractionState interaction; final ValueChanged? onLinkActivate; - const TerminalGestureDetector({ + const InteractionRegion({ super.key, required this.child, required this.links, @@ -53,12 +53,10 @@ final class TerminalGestureDetector extends StatefulWidget { }); @override - State createState() => - _TerminalGestureDetectorState(); + State createState() => _InteractionRegionState(); } -final class _TerminalGestureDetectorState - extends State { +final class _InteractionRegionState extends State { static const _mouseButtons = { kPrimaryMouseButton: .left, kMiddleMouseButton: .middle, @@ -83,7 +81,7 @@ final class _TerminalGestureDetectorState var _terminalDragActive = false; var _terminalOwnsInteraction = false; - TerminalViewAttachment get _attachment => widget.attachment; + ViewAttachment get _attachment => widget.attachment; @override Widget build(BuildContext context) { @@ -94,13 +92,13 @@ final class _TerminalGestureDetectorState onPointerHover: _handleTrackedHover, onPointerUp: _handleTrackedUp, onPointerCancel: _handleTrackedCancel, - child: TerminalScrollGestureHandler( + child: ScrollGestureRegion( metrics: widget.metrics, attachment: widget.attachment, physics: widget.scrollPhysics, interaction: widget.interaction, onScrollStart: _handleScrollStart, - child: TerminalRawGestureDetector( + child: PrimitiveGestureDetector( onTapDown: _handleTapDown, onTapUp: _handleTapUp, onDragStart: _handleDragStart, @@ -116,7 +114,7 @@ final class _TerminalGestureDetectorState } @override - void didUpdateWidget(TerminalGestureDetector oldWidget) { + void didUpdateWidget(InteractionRegion oldWidget) { super.didUpdateWidget(oldWidget); final attachmentChanged = widget.attachment != oldWidget.attachment; if (attachmentChanged) { @@ -160,7 +158,7 @@ final class _TerminalGestureDetectorState } _attachment.updateSelectionAutoscroll( - TerminalSelectionAutoscrollEvent( + SelectionAutoscrollInput( cell: drag.cell, pixelX: drag.localPosition.dx, pixelY: drag.localPosition.dy, @@ -187,7 +185,7 @@ final class _TerminalGestureDetectorState } void _cancelSelectionInteraction( - TerminalViewAttachment attachment, { + ViewAttachment attachment, { bool clearSelection = false, }) { if (clearSelection || _drag != null || _pressCell != null) { @@ -285,7 +283,7 @@ final class _TerminalGestureDetectorState final settings = widget.settings; final cell = widget.metrics.cellAt(position); _attachment.handleSelectionPress( - TerminalSelectionPressEvent( + SelectionPressInput( cell: cell, pixelX: position.dx, pixelY: position.dy, @@ -459,7 +457,7 @@ final class _TerminalGestureDetectorState void _releaseTrackedPointer( int pointerId, Offset position, { - TerminalViewAttachment? attachment, + ViewAttachment? attachment, }) { final pointer = _activePointers[pointerId]; if (pointer == null) return; @@ -487,7 +485,7 @@ final class _TerminalGestureDetectorState _activePointers.remove(pointerId); } - void _releaseTrackedPointers(TerminalViewAttachment attachment) { + void _releaseTrackedPointers(ViewAttachment attachment) { _activePointers.removeWhere((_, pointer) => pointer.kind == .touch); while (_activePointers.isNotEmpty) { final entry = _activePointers.entries.first; @@ -503,11 +501,11 @@ final class _TerminalGestureDetectorState MouseAction action, Offset position, { MouseButton? button, - TerminalViewAttachment? attachment, + ViewAttachment? attachment, }) { final target = attachment ?? _attachment; target.handleMouseEvent( - TerminalMouseEvent( + MouseInput( action: action, anyButtonPressed: _activePointers.values.any( (pointer) => pointer.buttons != 0, @@ -594,7 +592,7 @@ final class _TerminalGestureDetectorState drag.lastRectangle = rectangle; _attachment.updateSelectionDrag( - TerminalSelectionDragEvent( + SelectionDragInput( cell: clampedCell, pixelX: position.dx, pixelY: position.dy, @@ -607,7 +605,7 @@ final class _TerminalGestureDetectorState _TrackedPointer pointer, int buttons, Offset position, { - TerminalViewAttachment? attachment, + ViewAttachment? attachment, }) { final nextButtons = buttons & _supportedMouseButtons; final previousButtons = pointer.buttons; @@ -650,7 +648,7 @@ final class _TerminalGestureDetectorState _TrackedPointer pointer, int buttons, Offset position, { - TerminalViewAttachment? attachment, + ViewAttachment? attachment, }) { final previousButton = pointer.buttons == 0 ? null : pointer.button; final nextButton = _stylusButtonForMask(buttons); diff --git a/packages/flterm/lib/src/input/terminal_input_adapter.dart b/packages/flterm/lib/src/input/keyboard_input_adapter.dart similarity index 89% rename from packages/flterm/lib/src/input/terminal_input_adapter.dart rename to packages/flterm/lib/src/input/keyboard_input_adapter.dart index ce99881c..9a57640d 100644 --- a/packages/flterm/lib/src/input/terminal_input_adapter.dart +++ b/packages/flterm/lib/src/input/keyboard_input_adapter.dart @@ -5,34 +5,35 @@ import 'package:libghostty/libghostty.dart' hide KeyEvent; import '../controller/terminal_controller.dart'; import '../foundation.dart'; -import 'terminal_input_client.dart'; -import 'terminal_input_event.dart'; +import 'input_message.dart'; +import 'text_input_session.dart'; -/// Adapts one Flutter focus and keyboard lifecycle to terminal input. +/// Coordinates hardware-key and platform text-input handling for a +/// [TerminalView]. /// -/// The adapter combines physical and virtual modifiers, routes raw key events, -/// owns the view's [TerminalInputClient], and publishes visible IME preedit -/// text. Terminal protocol encoding remains controller-owned. +/// The adapter owns the focus binding and [TextInputSession], combines physical +/// and virtual modifiers, routes raw key events, and publishes visible IME +/// preedit text. Terminal protocol encoding remains controller-owned. /// /// Attachment is view-bound: replacing the focus node or Flutter view ID /// detaches the platform text-input connection before rebinding it. Raw keys /// and text deltas converge on controller methods, so neither path writes /// directly to libghostty or invokes public output callbacks. @internal -final class TerminalInputAdapter extends ChangeNotifier { +final class KeyboardInputAdapter extends ChangeNotifier { static const _space = 0x20; static const _delete = 0x7f; static const _macFunctionKeyStart = 0xF700; static const _macFunctionKeyEnd = 0xF8FF; final TerminalControllerImpl _controller; - final _textInput = TerminalInputClient(); + final _textInput = TextInputSession(); FocusNode? _focusNode; Brightness _keyboardAppearance = .dark; var _preeditText = ''; var _wasFocused = false; - TerminalInputAdapter(this._controller) { + KeyboardInputAdapter(this._controller) { _textInput ..onTextCommitted = _controller.handleTextCommitted ..onDelete = _controller.handleTextDeleted @@ -110,10 +111,10 @@ final class TerminalInputAdapter extends ChangeNotifier { } KeyEventResult handleKeyEvent(KeyEvent event) { - final KeyAction? action = switch (event) { - KeyDownEvent() => .press, - KeyUpEvent() => .release, - KeyRepeatEvent() => .repeat, + final action = switch (event) { + KeyDownEvent() => KeyAction.press, + KeyUpEvent() => KeyAction.release, + KeyRepeatEvent() => KeyAction.repeat, _ => null, }; if (action == null) return .ignored; @@ -130,10 +131,10 @@ final class TerminalInputAdapter extends ChangeNotifier { ); final consumedMods = physicalConsumedMods ^ (physicalConsumedMods & virtualMods); - final terminalMods = consumedMods.hasCtrl ? mods ^ const .ctrl() : mods; + final terminalMods = consumedMods.hasCtrl ? mods ^ const Mods.ctrl() : mods; final composing = _textInput.hasActiveComposition || _preeditText.isNotEmpty; - final input = TerminalKeyInput( + final input = KeyInput( key: key, action: action, mods: terminalMods, @@ -197,14 +198,14 @@ final class TerminalInputAdapter extends ChangeNotifier { notifyListeners(); } - bool _shouldForwardCompositionKey(TerminalKeyInput input) { + bool _shouldForwardCompositionKey(KeyInput input) { return input.composing && _textInput.isAttached && _isDesktopPlatform && !_shouldRouteToTextInput(input); } - bool _shouldForwardDeletion(TerminalKeyInput input) { + bool _shouldForwardDeletion(KeyInput input) { if (!_isDesktopPlatform || !_controller.virtualMods.isEmpty) return false; if (input.action != .press && input.action != .repeat) return false; if (input.key != .backspace && input.key != .delete) return false; @@ -215,7 +216,7 @@ final class TerminalInputAdapter extends ChangeNotifier { return _textInput.consumeCommittedCompositionEdit(); } - bool _shouldRouteToTextInput(TerminalKeyInput input) { + bool _shouldRouteToTextInput(KeyInput input) { if (input.character == null || input.composing) return false; if (!_textInput.isAttached || !_isDesktopPlatform) return false; if (input.action != .press && input.action != .repeat) return false; diff --git a/packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart b/packages/flterm/lib/src/input/primitive_gesture_detector.dart similarity index 97% rename from packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart rename to packages/flterm/lib/src/input/primitive_gesture_detector.dart index 6072da57..66e5b111 100644 --- a/packages/flterm/lib/src/input/terminal_raw_gesture_detector.dart +++ b/packages/flterm/lib/src/input/primitive_gesture_detector.dart @@ -9,7 +9,7 @@ import 'package:meta/meta.dart'; /// source timestamp because Flutter's resolved tap details do not expose it. /// This widget reports gestures only and owns no selection or terminal state. @internal -final class TerminalRawGestureDetector extends StatelessWidget { +final class PrimitiveGestureDetector extends StatelessWidget { final Widget child; /// Fires when a tap begins with its source pointer timestamp. @@ -36,7 +36,7 @@ final class TerminalRawGestureDetector extends StatelessWidget { /// Fires when a touch long press ends. final VoidCallback? onLongPressUp; - const TerminalRawGestureDetector({ + const PrimitiveGestureDetector({ super.key, required this.child, this.onTapDown, diff --git a/packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart b/packages/flterm/lib/src/input/scroll_gesture_region.dart similarity index 87% rename from packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart rename to packages/flterm/lib/src/input/scroll_gesture_region.dart index 05f01a6d..6ac7c484 100644 --- a/packages/flterm/lib/src/input/terminal_scroll_gesture_handler.dart +++ b/packages/flterm/lib/src/input/scroll_gesture_region.dart @@ -1,4 +1,5 @@ -import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; @@ -6,13 +7,11 @@ import 'package:libghostty/libghostty.dart' show Mods; import 'package:meta/meta.dart'; import '../foundation.dart'; -import '../view/terminal_view_attachment.dart'; -import 'terminal_input_event.dart'; +import '../view/view_attachment.dart'; +import 'input_message.dart'; typedef _ScrollTarget = ({Offset position, Mods mods, bool reportMouse}); -enum _ScrollGestureMode { pan, vertical } - /// Owns terminal-directed wheel, touch, and trackpad scrolling. /// /// This component captures one target for each gesture sequence, quantizes @@ -21,15 +20,15 @@ enum _ScrollGestureMode { pan, vertical } /// recognizer; alternate-screen key scrolling uses a vertical recognizer so /// horizontal gestures remain available to ancestor widgets. @internal -final class TerminalScrollGestureHandler extends StatefulWidget { +final class ScrollGestureRegion extends StatefulWidget { final Widget child; final CellMetrics metrics; final ScrollPhysics physics; - final TerminalViewAttachment attachment; - final TerminalInteractionState interaction; + final ViewAttachment attachment; + final ViewInteractionState interaction; final ValueChanged onScrollStart; - const TerminalScrollGestureHandler({ + const ScrollGestureRegion({ super.key, required this.metrics, required this.physics, @@ -40,12 +39,10 @@ final class TerminalScrollGestureHandler extends StatefulWidget { }); @override - State createState() => - _TerminalScrollGestureState(); + State createState() => _ScrollGestureRegionState(); } -final class _TerminalScrollGestureState - extends State +final class _ScrollGestureRegionState extends State with SingleTickerProviderStateMixin { static const _macOsDiscreteScrollPixels = 40.0; static const _macOsDiscreteVerticalMultiplier = 3.0; @@ -62,18 +59,14 @@ final class _TerminalScrollGestureState child: RawGestureDetector( behavior: .opaque, gestures: { - _TerminalPanGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - _TerminalPanGestureRecognizer - >( - () => _TerminalPanGestureRecognizer(debugOwner: this), + _TwoAxisScrollRecognizer: + GestureRecognizerFactoryWithHandlers<_TwoAxisScrollRecognizer>( + () => _TwoAxisScrollRecognizer(debugOwner: this), (recognizer) => _configure(recognizer, .pan), ), - _TerminalVerticalDragGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - _TerminalVerticalDragGestureRecognizer - >( - () => _TerminalVerticalDragGestureRecognizer(debugOwner: this), + _VerticalScrollRecognizer: + GestureRecognizerFactoryWithHandlers<_VerticalScrollRecognizer>( + () => _VerticalScrollRecognizer(debugOwner: this), (recognizer) => _configure(recognizer, .vertical), ), }, @@ -83,7 +76,7 @@ final class _TerminalScrollGestureState } @override - void didUpdateWidget(TerminalScrollGestureHandler oldWidget) { + void didUpdateWidget(ScrollGestureRegion oldWidget) { super.didUpdateWidget(oldWidget); if (widget.attachment != oldWidget.attachment || widget.metrics != oldWidget.metrics || @@ -106,8 +99,25 @@ final class _TerminalScrollGestureState _ticker = createTicker(_tick); } + void _beginGesture(PointerEvent event) { + final carriedVelocity = _activity?.velocity ?? Offset.zero; + _stopBallistic(); + _activity = _ScrollActivity( + target: _targetAt(event.localPosition), + kind: event.kind, + physics: widget.physics, + carriedVelocity: carriedVelocity, + timeStamp: event.timeStamp, + ); + } + + void _cancelGesture() { + _activity = null; + _ticker.stop(); + } + void _configure(DragGestureRecognizer recognizer, _ScrollGestureMode mode) { - (recognizer as _TerminalScrollSequence).configureSequence( + (recognizer as _ScrollSequence).configureSequence( canStart: () => _gestureMode() == mode, onPointerStart: _beginGesture, ); @@ -127,45 +137,17 @@ final class _TerminalScrollGestureState ..gestureSettings = MediaQuery.maybeGestureSettingsOf(context); } - _ScrollGestureMode? _gestureMode() { - final metrics = widget.metrics; - if ((_activity?.isDragging ?? false) || - !widget.physics.allowUserScrolling || - !metrics.cellWidth.isFinite || - metrics.cellWidth <= 0 || - !metrics.cellHeight.isFinite || - metrics.cellHeight <= 0) { - return null; - } - if (widget.attachment.mouseTracking != .none) { - return widget.attachment.currentMods.hasShift ? null : .pan; - } - final terminal = widget.attachment.terminal; - return terminal.activeScreen == .alternate && - terminal.modeGet(const .alternateScroll()) - ? .vertical - : null; - } - - void _beginGesture(PointerEvent event) { - final carriedVelocity = _activity?.velocity ?? Offset.zero; - _stopBallistic(); - _activity = _ScrollActivity( - target: _targetAt(event.localPosition), - kind: event.kind, - physics: widget.physics, - carriedVelocity: carriedVelocity, - timeStamp: event.timeStamp, - ); + int _discreteHorizontalTicks(double delta) { + if (delta == 0) return 0; + final magnitude = (delta.abs() / _macOsDiscreteScrollPixels).round(); + final ticks = magnitude < 1 ? 1 : magnitude; + return delta < 0 ? -ticks : ticks; } - void _updateGesture(DragUpdateDetails details) { - final activity = _activity; - if (activity == null || !activity.isDragging) return; - final delta = -details.delta; - if (activity.markMoved(delta)) widget.onScrollStart(activity.kind); - final adjusted = activity.update(delta, details.sourceTimeStamp); - if (adjusted != Offset.zero) _route(adjusted, activity.target); + double _discreteVerticalTicks(double delta) { + if (delta == 0) return 0; + final ticks = delta / _macOsDiscreteScrollPixels; + return ticks.abs() < 1 ? ticks.sign : ticks; } void _endGesture(DragEndDetails details) { @@ -185,14 +167,24 @@ final class _TerminalScrollGestureState _ticker.start(); } - void _cancelGesture() { - _activity = null; - _ticker.stop(); - } - - void _reset() { - _cancelGesture(); - _remainder = null; + _ScrollGestureMode? _gestureMode() { + final metrics = widget.metrics; + if ((_activity?.isDragging ?? false) || + !widget.physics.allowUserScrolling || + !metrics.cellWidth.isFinite || + metrics.cellWidth <= 0 || + !metrics.cellHeight.isFinite || + metrics.cellHeight <= 0) { + return null; + } + if (widget.attachment.mouseTracking != .none) { + return widget.attachment.currentMods.hasShift ? null : .pan; + } + final terminal = widget.attachment.terminal; + return terminal.activeScreen == .alternate && + terminal.modeGet(const .alternateScroll()) + ? .vertical + : null; } void _handlePointerSignal(PointerSignalEvent event) { @@ -230,17 +222,10 @@ final class _TerminalScrollGestureState event.respond(allowPlatformDefault: false); } - _ScrollTarget _targetAt(Offset position) { - final mods = widget.attachment.currentMods; - return ( - mods: mods, - position: position, - reportMouse: widget.attachment.mouseTracking != .none && !mods.hasShift, - ); - } - Offset _normalizePointerScroll(PointerScrollEvent event) { - if (kIsWeb || defaultTargetPlatform != .macOS || event.kind != .mouse) { + if (kIsWeb || + defaultTargetPlatform != TargetPlatform.macOS || + event.kind != PointerDeviceKind.mouse) { return event.scrollDelta; } @@ -254,17 +239,9 @@ final class _TerminalScrollGestureState ); } - int _discreteHorizontalTicks(double delta) { - if (delta == 0) return 0; - final magnitude = (delta.abs() / _macOsDiscreteScrollPixels).round(); - final ticks = magnitude < 1 ? 1 : magnitude; - return delta < 0 ? -ticks : ticks; - } - - double _discreteVerticalTicks(double delta) { - if (delta == 0) return 0; - final ticks = delta / _macOsDiscreteScrollPixels; - return ticks.abs() < 1 ? ticks.sign : ticks; + void _reset() { + _cancelGesture(); + _remainder = null; } void _route(Offset delta, _ScrollTarget target) { @@ -284,25 +261,17 @@ final class _TerminalScrollGestureState if (horizontal == 0 && vertical == 0) return; widget.attachment.handleTerminalScroll( - TerminalScrollEvent( - mods: target.mods, - vertical: vertical, + ScrollInput( horizontal: horizontal, + mods: target.mods, pixelX: target.position.dx, pixelY: target.position.dy, reportMouse: target.reportMouse, + vertical: vertical, ), ); } - void _tick(Duration elapsed) { - final activity = _activity; - if (activity == null || activity.isDragging) return; - final delta = activity.advance(elapsed); - if (delta != Offset.zero) _route(delta, activity.target); - if (activity.done) _stopBallistic(); - } - void _stopBallistic() { _ticker.stop(); final activity = _activity; @@ -314,6 +283,32 @@ final class _TerminalScrollGestureState } } + _ScrollTarget _targetAt(Offset position) { + final mods = widget.attachment.currentMods; + return ( + position: position, + mods: mods, + reportMouse: widget.attachment.mouseTracking != .none && !mods.hasShift, + ); + } + + void _tick(Duration elapsed) { + final activity = _activity; + if (activity == null || activity.isDragging) return; + final delta = activity.advance(elapsed); + if (delta != Offset.zero) _route(delta, activity.target); + if (activity.done) _stopBallistic(); + } + + void _updateGesture(DragUpdateDetails details) { + final activity = _activity; + if (activity == null || !activity.isDragging) return; + final delta = -details.delta; + if (activity.markMoved(delta)) widget.onScrollStart(activity.kind); + final adjusted = activity.update(delta, details.sourceTimeStamp); + if (adjusted != Offset.zero) _route(adjusted, activity.target); + } + static Offset _supportedDelta(Offset delta, _ScrollTarget target) { return target.reportMouse ? delta : Offset(0, delta.dy); } @@ -383,7 +378,7 @@ final class _ScrollActivity { } bool markMoved(Offset delta) { - if (_supported(delta) == .zero || _moved) return false; + if (_supported(delta) == Offset.zero || _moved) return false; _moved = true; return true; } @@ -430,43 +425,8 @@ final class _ScrollActivity { } } -/// Accumulates sub-cell motion for one compatible terminal scroll target. -/// -/// Mouse-reporting remainders are tied to their cell and modifier snapshot; -/// alternate-scroll remainders can continue across positions because only -/// vertical key steps are emitted. -final class _ScrollRemainder { - final CellMetrics metrics; - final _ScrollTarget target; - double horizontal; - double vertical; - - _ScrollRemainder(this.target, this.metrics) : horizontal = 0, vertical = 0; - - bool shares(_ScrollTarget other, CellMetrics otherMetrics) { - if (metrics != otherMetrics || target.reportMouse != other.reportMouse) { - return false; - } - if (!other.reportMouse) return true; - return target.mods == other.mods && - metrics.cellAt(target.position) == metrics.cellAt(other.position); - } -} - /// Per-axis motion state matching Flutter's scroll-drag momentum behavior. final class _ScrollAxis { - /// Finite synthetic extents let Flutter create a ballistic simulation even - /// though the terminal routes the resulting deltas rather than using a - /// Flutter scroll position. - static const _simulationExtent = 1e9; - - /// Prevents zero-sized test or detached surfaces from producing invalid - /// scroll metrics for the simulation. - static const _minimumViewportDimension = 1.0; - - /// Keeps fallback scroll metrics valid when a platform reports no scale. - static const _minimumDevicePixelRatio = 1.0; - static const _largeThresholdBreakDistance = 24.0; static const _motionStoppedThreshold = Duration(milliseconds: 50); @@ -487,10 +447,15 @@ final class _ScrollAxis { bool get done => _simulation == null; - double update(double delta, Duration? timeStamp) { - if (delta != 0) _lastMovement = timeStamp; - _updateMomentum(delta, timeStamp); - return _applyMotionStartThreshold(delta, timeStamp); + double applyMomentumTo(double replacement) { + if (!_retainsMomentum || + replacement.sign != carriedVelocity.sign || + replacement.abs() <= + carriedVelocity.abs() * + ScrollDragController.momentumRetainVelocityThresholdFactor) { + return replacement; + } + return replacement + carriedVelocity; } void dropMomentum() => _retainsMomentum = false; @@ -513,39 +478,33 @@ final class _ScrollAxis { _simulation = velocity == 0 ? null : physics.createBallisticSimulation( + // The terminal consumes deltas instead of using a ScrollPosition. + // Finite extents and non-zero fallbacks keep Flutter's synthetic + // scroll metrics valid for detached or zero-sized surfaces. FixedScrollMetrics( + minScrollExtent: -1e9, + maxScrollExtent: 1e9, pixels: 0, - maxScrollExtent: _simulationExtent, - minScrollExtent: -_simulationExtent, + viewportDimension: viewportDimension > 0 ? viewportDimension : 1, axisDirection: axis == .horizontal ? .right : .down, - devicePixelRatio: devicePixelRatio > 0 - ? devicePixelRatio - : _minimumDevicePixelRatio, - viewportDimension: viewportDimension > 0 - ? viewportDimension - : _minimumViewportDimension, + devicePixelRatio: devicePixelRatio > 0 ? devicePixelRatio : 1, ), velocity, ); } + double update(double delta, Duration? timeStamp) { + if (delta != 0) _lastMovement = timeStamp; + _updateMomentum(delta, timeStamp); + return _applyMotionStartThreshold(delta, timeStamp); + } + double velocityAt(double time) { final simulation = _simulation; if (simulation == null || simulation.isDone(time)) return 0; return simulation.dx(time); } - double applyMomentumTo(double replacement) { - if (!_retainsMomentum || - replacement.sign != carriedVelocity.sign || - replacement.abs() <= - carriedVelocity.abs() * - ScrollDragController.momentumRetainVelocityThresholdFactor) { - return replacement; - } - return replacement + carriedVelocity; - } - double _applyMotionStartThreshold(double delta, Duration? timeStamp) { final threshold = motionStartDistanceThreshold; if (timeStamp == null || threshold == null) return delta; @@ -581,9 +540,66 @@ final class _ScrollAxis { } } -final class _TerminalPanGestureRecognizer extends PanGestureRecognizer - with _TerminalScrollSequence { - _TerminalPanGestureRecognizer({super.debugOwner}) +enum _ScrollGestureMode { pan, vertical } + +/// Accumulates sub-cell motion for one compatible terminal scroll target. +/// +/// Mouse-reporting remainders are tied to their cell and modifier snapshot; +/// alternate-scroll remainders can continue across positions because only +/// vertical key steps are emitted. +final class _ScrollRemainder { + final CellMetrics metrics; + final _ScrollTarget target; + double horizontal; + double vertical; + + _ScrollRemainder(this.target, this.metrics) : horizontal = 0, vertical = 0; + + bool shares(_ScrollTarget other, CellMetrics otherMetrics) { + if (metrics != otherMetrics || target.reportMouse != other.reportMouse) { + return false; + } + if (!other.reportMouse) return true; + return target.mods == other.mods && + metrics.cellAt(target.position) == metrics.cellAt(other.position); + } +} + +/// Keeps one recognizer eligible for the full pointer sequence it accepted. +/// +/// Eligibility is sampled only at sequence start. This prevents modifier or +/// terminal-mode changes from transferring an in-flight sequence between the +/// pan and vertical recognizers. +mixin _ScrollSequence { + late ValueGetter _canStart; + late ValueChanged _onPointerStart; + PointerDeviceKind? _activeKind; + + bool allowsSequence(PointerEvent event) { + final activeKind = _activeKind; + return activeKind == null ? _canStart() : activeKind == event.kind; + } + + void configureSequence({ + required ValueGetter canStart, + required ValueChanged onPointerStart, + }) { + _canStart = canStart; + _onPointerStart = onPointerStart; + } + + void startSequence(PointerEvent event) { + if (_activeKind != null) return; + _activeKind = event.kind; + _onPointerStart(event); + } + + void stopSequence() => _activeKind = null; +} + +final class _TwoAxisScrollRecognizer extends PanGestureRecognizer + with _ScrollSequence { + _TwoAxisScrollRecognizer({super.debugOwner}) : super(supportedDevices: const {.touch, .trackpad}); @override @@ -598,6 +614,12 @@ final class _TerminalPanGestureRecognizer extends PanGestureRecognizer super.addAllowedPointerPanZoom(event); } + @override + void didStopTrackingLastPointer(int pointer) { + super.didStopTrackingLastPointer(pointer); + stopSequence(); + } + @override bool isPointerAllowed(PointerEvent event) { return allowsSequence(event) && super.isPointerAllowed(event); @@ -607,18 +629,11 @@ final class _TerminalPanGestureRecognizer extends PanGestureRecognizer bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { return allowsSequence(event) && super.isPointerPanZoomAllowed(event); } - - @override - void didStopTrackingLastPointer(int pointer) { - super.didStopTrackingLastPointer(pointer); - stopSequence(); - } } -final class _TerminalVerticalDragGestureRecognizer - extends VerticalDragGestureRecognizer - with _TerminalScrollSequence { - _TerminalVerticalDragGestureRecognizer({super.debugOwner}) +final class _VerticalScrollRecognizer extends VerticalDragGestureRecognizer + with _ScrollSequence { + _VerticalScrollRecognizer({super.debugOwner}) : super(supportedDevices: const {.touch, .trackpad}); @override @@ -633,51 +648,19 @@ final class _TerminalVerticalDragGestureRecognizer super.addAllowedPointerPanZoom(event); } - @override - bool isPointerAllowed(PointerEvent event) { - return allowsSequence(event) && super.isPointerAllowed(event); - } - - @override - bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { - return allowsSequence(event) && super.isPointerPanZoomAllowed(event); - } - @override void didStopTrackingLastPointer(int pointer) { super.didStopTrackingLastPointer(pointer); stopSequence(); } -} - -/// Keeps one recognizer eligible for the full pointer sequence it accepted. -/// -/// Eligibility is sampled only at sequence start. This prevents modifier or -/// terminal-mode changes from transferring an in-flight sequence between the -/// pan and vertical recognizers. -mixin _TerminalScrollSequence { - late ValueGetter _canStart; - late ValueChanged _onPointerStart; - PointerDeviceKind? _activeKind; - void configureSequence({ - required ValueGetter canStart, - required ValueChanged onPointerStart, - }) { - _canStart = canStart; - _onPointerStart = onPointerStart; - } - - bool allowsSequence(PointerEvent event) { - final activeKind = _activeKind; - return activeKind == null ? _canStart() : activeKind == event.kind; + @override + bool isPointerAllowed(PointerEvent event) { + return allowsSequence(event) && super.isPointerAllowed(event); } - void startSequence(PointerEvent event) { - if (_activeKind != null) return; - _activeKind = event.kind; - _onPointerStart(event); + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { + return allowsSequence(event) && super.isPointerPanZoomAllowed(event); } - - void stopSequence() => _activeKind = null; } diff --git a/packages/flterm/lib/src/input/terminal_input_client.dart b/packages/flterm/lib/src/input/text_input_session.dart similarity index 97% rename from packages/flterm/lib/src/input/terminal_input_client.dart rename to packages/flterm/lib/src/input/text_input_session.dart index 5db40b6e..ab87dd03 100644 --- a/packages/flterm/lib/src/input/terminal_input_client.dart +++ b/packages/flterm/lib/src/input/text_input_session.dart @@ -16,7 +16,7 @@ import 'package:meta/meta.dart'; /// dedupe counters ensure one terminal newline while leaving later input /// untouched. @internal -final class TerminalInputClient with DeltaTextInputClient { +final class TextInputSession with DeltaTextInputClient { static const _newlineActionDedupeWindow = Duration(milliseconds: 100); static const _sentinel = TextEditingValue( selection: .collapsed(offset: 1), @@ -416,7 +416,7 @@ final class TerminalInputClient with DeltaTextInputClient { enum _CommittedCompositionEdit { none, pending, suppressNextDeletionDelta } /// Extracts terminal-owned text from Flutter's sentinel editing value. -extension _TerminalEditingValue on TextEditingValue { +extension _EditingValue on TextEditingValue { bool get hasTerminalComposingRange { final composing = this.composing; return composing.isValid && @@ -438,14 +438,14 @@ extension _TerminalEditingValue on TextEditingValue { var contentStart = start; if (start == 0 && end > 0 && - text.startsWith(TerminalInputClient._sentinel.text)) { - contentStart = TerminalInputClient._sentinel.text.length; + text.startsWith(TextInputSession._sentinel.text)) { + contentStart = TextInputSession._sentinel.text.length; } if (contentStart >= end) return ''; return text.substring(contentStart, end); } } -extension _TerminalInputString on String { +extension _InputString on String { bool get isImeLikeCommit => codeUnits.any((codeUnit) => codeUnit > 0x7f); } diff --git a/packages/flterm/lib/src/interaction/terminal_selection.dart b/packages/flterm/lib/src/interaction/selection_session.dart similarity index 82% rename from packages/flterm/lib/src/interaction/terminal_selection.dart rename to packages/flterm/lib/src/interaction/selection_session.dart index ac77cb3a..58a28bc0 100644 --- a/packages/flterm/lib/src/interaction/terminal_selection.dart +++ b/packages/flterm/lib/src/interaction/selection_session.dart @@ -1,9 +1,154 @@ -import 'package:libghostty/libghostty.dart' hide TerminalGeometry; +import 'package:libghostty/libghostty.dart'; import 'package:meta/meta.dart'; -import '../foundation/terminal_geometry.dart'; +import '../foundation/surface_geometry.dart'; import 'selection_gesture_driver.dart'; +/// A normalized terminal selection autoscroll update. +@immutable +final class SelectionAutoscrollInput { + /// The grid cell derived from the pointer position. + /// + /// The selection session clamps this value to the viewport before use. + final Position cell; + + /// The horizontal logical pixel offset from the terminal grid origin. + final double pixelX; + + /// The vertical logical pixel offset from the terminal grid origin. + final double pixelY; + + /// Whether the selection is rectangular. + final bool rectangle; + + const SelectionAutoscrollInput({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.rectangle, + }); + + @override + int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); + + @override + bool operator ==(Object other) { + return other is SelectionAutoscrollInput && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.rectangle == rectangle; + } +} + +/// A normalized terminal selection drag. +@immutable +final class SelectionDragInput { + /// The viewport cell under the pointer. + final Position cell; + + /// The horizontal logical pixel offset from the terminal grid origin. + final double pixelX; + + /// The vertical logical pixel offset from the terminal grid origin. + final double pixelY; + + /// Whether the selection is rectangular. + final bool rectangle; + + const SelectionDragInput({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.rectangle, + }); + + @override + int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); + + @override + bool operator ==(Object other) { + return other is SelectionDragInput && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.rectangle == rectangle; + } +} + +/// A normalized terminal selection press. +@immutable +final class SelectionPressInput { + /// The viewport cell under the pointer. + final Position cell; + + /// The horizontal logical pixel offset from the terminal grid origin. + final double pixelX; + + /// The vertical logical pixel offset from the terminal grid origin. + final double pixelY; + + /// Selection behavior for single, double, and triple presses. + final SelectionGestureBehaviors behaviors; + + /// Characters that split words during word selection. + /// + /// `null` uses the terminal defaults; an empty string supplies an explicit + /// empty boundary set. + final String? wordBoundaries; + + /// Maximum logical-pixel distance between repeated presses. + final double repeatDistance; + + /// Maximum interval between repeated presses. + final Duration repeatInterval; + + /// Monotonic source-event time used to classify repeated presses. + final Duration timeStamp; + + /// Whether line selection extends across the complete terminal row. + final bool fullWidthLine; + + const SelectionPressInput({ + required this.cell, + required this.pixelX, + required this.pixelY, + required this.behaviors, + required this.wordBoundaries, + required this.repeatDistance, + required this.repeatInterval, + required this.timeStamp, + required this.fullWidthLine, + }); + + @override + int get hashCode => Object.hash( + cell, + pixelX, + pixelY, + behaviors, + wordBoundaries, + repeatDistance, + repeatInterval, + timeStamp, + fullWidthLine, + ); + + @override + bool operator ==(Object other) { + return other is SelectionPressInput && + other.cell == cell && + other.pixelX == pixelX && + other.pixelY == pixelY && + other.behaviors == behaviors && + other.wordBoundaries == wordBoundaries && + other.repeatDistance == repeatDistance && + other.repeatInterval == repeatInterval && + other.timeStamp == timeStamp && + other.fullWidthLine == fullWidthLine; + } +} + /// Owns terminal selection state, measured bounds, and gesture continuation. /// /// It converts normalized view input into terminal grid references, clamps @@ -11,7 +156,7 @@ import 'selection_gesture_driver.dart'; /// when the effective selection changes. Gesture continuation is delegated to /// [SelectionGestureDriver], while this owner remains responsible for storing /// the resulting selection on the terminal and suppressing equivalent updates. -final class TerminalSelection { +final class SelectionSession { final void Function() _notifyChanged; final Terminal _terminal; late final SelectionGestureDriver _gesture; @@ -20,7 +165,7 @@ final class TerminalSelection { var _columns = 0; var _rows = 0; - TerminalSelection(this._terminal, this._notifyChanged) { + SelectionSession(this._terminal, this._notifyChanged) { _gesture = SelectionGestureDriver(_terminal); } @@ -55,7 +200,7 @@ final class TerminalSelection { return true; } - void handleAutoscroll(TerminalSelectionAutoscrollEvent event) { + void handleAutoscroll(SelectionAutoscrollInput event) { if (_columns <= 0 || _rows <= 0) return; _set( _gesture.autoscroll( @@ -68,7 +213,7 @@ final class TerminalSelection { ); } - void handleDrag(TerminalSelectionDragEvent event) { + void handleDrag(SelectionDragInput event) { final ref = _viewportRef(event.cell); if (ref == null) return; _set( @@ -82,7 +227,7 @@ final class TerminalSelection { ); } - void handlePress(TerminalSelectionPressEvent event) { + void handlePress(SelectionPressInput event) { final ref = _viewportRef(event.cell); if (ref == null) { _set(null, clearIfNull: true); @@ -141,7 +286,7 @@ final class TerminalSelection { ); } - void updateGeometry(TerminalGeometry geometry) { + void updateGeometry(SurfaceGeometry geometry) { _columns = geometry.cols; _rows = geometry.rows; _cellWidth = geometry.cellWidth; @@ -214,143 +359,3 @@ final class TerminalSelection { return .at(_terminal, _clampViewportPoint(position), pointTag: .viewport); } } - -/// A normalized terminal selection autoscroll update. -@immutable -final class TerminalSelectionAutoscrollEvent { - /// The viewport cell under the pointer. - final Position cell; - - /// The pointer's logical horizontal position. - final double pixelX; - - /// The pointer's logical vertical position. - final double pixelY; - - /// Whether the selection is rectangular. - final bool rectangle; - - const TerminalSelectionAutoscrollEvent({ - required this.cell, - required this.pixelX, - required this.pixelY, - required this.rectangle, - }); - - @override - int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); - - @override - bool operator ==(Object other) { - return other is TerminalSelectionAutoscrollEvent && - other.cell == cell && - other.pixelX == pixelX && - other.pixelY == pixelY && - other.rectangle == rectangle; - } -} - -/// A normalized terminal selection drag. -@immutable -final class TerminalSelectionDragEvent { - /// The viewport cell under the pointer. - final Position cell; - - /// The pointer's logical horizontal position. - final double pixelX; - - /// The pointer's logical vertical position. - final double pixelY; - - /// Whether the selection is rectangular. - final bool rectangle; - - const TerminalSelectionDragEvent({ - required this.cell, - required this.pixelX, - required this.pixelY, - required this.rectangle, - }); - - @override - int get hashCode => Object.hash(cell, pixelX, pixelY, rectangle); - - @override - bool operator ==(Object other) { - return other is TerminalSelectionDragEvent && - other.cell == cell && - other.pixelX == pixelX && - other.pixelY == pixelY && - other.rectangle == rectangle; - } -} - -/// A normalized terminal selection press. -@immutable -final class TerminalSelectionPressEvent { - /// The viewport cell under the pointer. - final Position cell; - - /// The pointer's logical horizontal position. - final double pixelX; - - /// The pointer's logical vertical position. - final double pixelY; - - /// Selection behavior for single-, double-, and triple-clicks. - final SelectionGestureBehaviors behaviors; - - /// Characters treated as word boundaries, or null for the default. - final String? wordBoundaries; - - /// Maximum distance between repeated clicks. - final double repeatDistance; - - /// Maximum interval between repeated clicks. - final Duration repeatInterval; - - /// Timestamp supplied by the pointer event source. - final Duration timeStamp; - - /// Whether a line selection expands to the full terminal width. - final bool fullWidthLine; - - const TerminalSelectionPressEvent({ - required this.cell, - required this.pixelX, - required this.pixelY, - required this.behaviors, - required this.wordBoundaries, - required this.repeatDistance, - required this.repeatInterval, - required this.timeStamp, - required this.fullWidthLine, - }); - - @override - int get hashCode => Object.hash( - cell, - pixelX, - pixelY, - behaviors, - wordBoundaries, - repeatDistance, - repeatInterval, - timeStamp, - fullWidthLine, - ); - - @override - bool operator ==(Object other) { - return other is TerminalSelectionPressEvent && - other.cell == cell && - other.pixelX == pixelX && - other.pixelY == pixelY && - other.behaviors == behaviors && - other.wordBoundaries == wordBoundaries && - other.repeatDistance == repeatDistance && - other.repeatInterval == repeatInterval && - other.timeStamp == timeStamp && - other.fullWidthLine == fullWidthLine; - } -} diff --git a/packages/flterm/lib/src/links/link_resolver.dart b/packages/flterm/lib/src/links/link_resolver.dart index f7841020..be72a6e9 100644 --- a/packages/flterm/lib/src/links/link_resolver.dart +++ b/packages/flterm/lib/src/links/link_resolver.dart @@ -5,8 +5,8 @@ import '../foundation/cell_range.dart'; import 'link_match.dart'; import 'link_settings.dart'; import 'link_snapshot.dart'; +import 'logical_line.dart'; import 'osc8_link_detector.dart'; -import 'terminal_logical_line.dart'; import 'text_link_detector.dart'; /// Resolves links from the visible terminal viewport. @@ -28,7 +28,7 @@ final class LinkResolver { }) { if (settings.types.isEmpty) return .empty; - final lines = TerminalLogicalLine.visible(terminal, rows: rows, cols: cols); + final lines = LogicalLine.visible(terminal, rows: rows, cols: cols); return LinkSnapshot( _matches(lines, settings, cwd: null), highlighted: highlighted, @@ -46,7 +46,7 @@ final class LinkResolver { }) { if (settings.types.isEmpty) return null; - final line = TerminalLogicalLine.atPosition( + final line = LogicalLine.atPosition( terminal, position, rows: rows, @@ -62,7 +62,7 @@ final class LinkResolver { } List _matches( - List lines, + List lines, LinkSettings settings, { required String? cwd, }) { diff --git a/packages/flterm/lib/src/links/terminal_logical_line.dart b/packages/flterm/lib/src/links/logical_line.dart similarity index 93% rename from packages/flterm/lib/src/links/terminal_logical_line.dart rename to packages/flterm/lib/src/links/logical_line.dart index d5fccf1e..a91e2a3f 100644 --- a/packages/flterm/lib/src/links/terminal_logical_line.dart +++ b/packages/flterm/lib/src/links/logical_line.dart @@ -10,7 +10,7 @@ import '../foundation/cell_range.dart'; /// every text offset has a source cell in [map], and every retained cell has a /// text start/end offset used by link detectors. @internal -final class TerminalLogicalLine { +final class LogicalLine { final String text; final List map; final List cells; @@ -18,11 +18,11 @@ final class TerminalLogicalLine { final List _cellStartOffsets; final List _cellEndOffsets; - TerminalLogicalLine(this.text, this.map, this.cells, this.uris) + LogicalLine(this.text, this.map, this.cells, this.uris) : _cellStartOffsets = _startOffsetsFor(map, cells), _cellEndOffsets = _endOffsetsFor(map, cells); - const TerminalLogicalLine._( + const LogicalLine._( this.text, this.map, this.cells, @@ -70,7 +70,7 @@ final class TerminalLogicalLine { } /// Builds the wrapped logical line containing [position]. - static TerminalLogicalLine? atPosition( + static LogicalLine? atPosition( Terminal terminal, Position position, { required int rows, @@ -106,14 +106,14 @@ final class TerminalLogicalLine { } /// Builds every visible wrapped logical line in viewport order. - static List visible( + static List visible( Terminal terminal, { required int rows, required int cols, }) { if (rows <= 0 || cols <= 0) return const []; - final lines = []; + final lines = []; var current = _LogicalLineBuilder(); for (var row = 0; row < rows; row++) { @@ -169,7 +169,7 @@ final class _LogicalLineBuilder { bool get isEmpty => _cells.isEmpty; bool? addRow(Terminal terminal, int row, int cols) { - final firstCell = TerminalLogicalLine._cellAt(terminal, row, 0); + final firstCell = LogicalLine._cellAt(terminal, row, 0); if (firstCell == null) return null; final rowWrap = firstCell.rowWrap; @@ -177,7 +177,7 @@ final class _LogicalLineBuilder { final position = Position(row: row, col: col); final cell = col == 0 ? firstCell - : TerminalLogicalLine._cellAt(terminal, row, col); + : LogicalLine._cellAt(terminal, row, col); if (cell == null) break; if (cell.wide == .spacerTail) continue; @@ -201,14 +201,14 @@ final class _LogicalLineBuilder { return rowWrap; } - TerminalLogicalLine finish() { + LogicalLine finish() { final raw = _text.toString(); final end = raw.trimRight().length; final cellCount = _cellStartOffsets.lastIndexWhere( (offset) => offset < end, ); final cellsEnd = cellCount + 1; - return TerminalLogicalLine._( + return LogicalLine._( raw.substring(0, end), List.unmodifiable(_map.take(end)), List.unmodifiable(_cells.take(cellsEnd)), diff --git a/packages/flterm/lib/src/links/osc8_link_detector.dart b/packages/flterm/lib/src/links/osc8_link_detector.dart index f8337db9..50a28cc9 100644 --- a/packages/flterm/lib/src/links/osc8_link_detector.dart +++ b/packages/flterm/lib/src/links/osc8_link_detector.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart' show internal; import 'link_match.dart'; import 'link_settings.dart'; -import 'terminal_logical_line.dart'; +import 'logical_line.dart'; /// Detects OSC 8 links from cell metadata. /// @@ -16,7 +16,7 @@ final class Osc8LinkDetector { /// Each logical line carries one URI entry per retained cell. Adjacent cells /// with the same URI are grouped into one link, and null URI cells split the /// current run. - Iterable matches(List lines) sync* { + Iterable matches(List lines) sync* { for (final line in lines) { String? uri; var startIndex = -1; @@ -42,7 +42,7 @@ final class Osc8LinkDetector { } LinkMatch _matchFromCells( - TerminalLogicalLine line, + LogicalLine line, int start, int end, { required String uri, diff --git a/packages/flterm/lib/src/links/text_link_detector.dart b/packages/flterm/lib/src/links/text_link_detector.dart index 25fd81f7..ca87e5c9 100644 --- a/packages/flterm/lib/src/links/text_link_detector.dart +++ b/packages/flterm/lib/src/links/text_link_detector.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart' show internal; import 'link_match.dart'; import 'link_path_resolver.dart'; import 'link_settings.dart'; -import 'terminal_logical_line.dart'; +import 'logical_line.dart'; import 'text_link_patterns.dart'; /// Detects built-in text links and user-defined regex links. @@ -17,7 +17,7 @@ final class TextLinkDetector { /// The detector scans logical-line text with [TextLinkPatterns.link], trims /// prose punctuation, then normalizes the match into URI or file data. Iterable builtInMatches( - List lines, { + List lines, { required String? cwd, }) sync* { for (final line in lines) { @@ -51,7 +51,7 @@ final class TextLinkDetector { /// The rule runs against each logical line. Non-empty matches become custom /// links with their regex capture groups. Iterable customMatches( - List lines, + List lines, LinkRule rule, int sourceOrder, ) sync* { @@ -83,7 +83,7 @@ final class TextLinkDetector { } LinkMatch _matchFromOffsets( - TerminalLogicalLine line, + LogicalLine line, int start, int end, { required LinkType type, diff --git a/packages/flterm/lib/src/rendering.dart b/packages/flterm/lib/src/rendering.dart index b9375c78..a055aefa 100644 --- a/packages/flterm/lib/src/rendering.dart +++ b/packages/flterm/lib/src/rendering.dart @@ -1,5 +1,5 @@ export 'rendering/font/font_data_resolver.dart'; export 'rendering/font/font_table_metrics.dart'; export 'rendering/font/measure_cell_metrics.dart'; -export 'rendering/terminal_frame_source.dart'; +export 'rendering/frame_source.dart'; export 'rendering/terminal_renderer.dart'; diff --git a/packages/flterm/lib/src/rendering/terminal_render_cache.dart b/packages/flterm/lib/src/rendering/atlas_pool.dart similarity index 68% rename from packages/flterm/lib/src/rendering/terminal_render_cache.dart rename to packages/flterm/lib/src/rendering/atlas_pool.dart index c642dabf..c3591f9a 100644 --- a/packages/flterm/lib/src/rendering/terminal_render_cache.dart +++ b/packages/flterm/lib/src/rendering/atlas_pool.dart @@ -1,15 +1,19 @@ import 'atlas/atlas.dart'; -class TerminalAtlasHandle { - final TerminalRenderCache _owner; +/// Keeps a shared [Atlas] alive until [release] is called. +class AtlasLease { + final AtlasPool _owner; final AtlasConfig config; final _CachedAtlas _entry; var _released = false; - TerminalAtlasHandle._(this._owner, this.config, this._entry); + AtlasLease._(this._owner, this.config, this._entry); Atlas get atlas => _entry.atlas; + /// Releases this lease. + /// + /// Repeated calls have no effect. void release() { if (_released) return; _released = true; @@ -23,18 +27,23 @@ class TerminalAtlasHandle { /// theme/metrics/DPR and use it directly as the sharing key. /// /// This type is internal; public sharing is exposed through `TerminalScope`. -class TerminalRenderCache { +class AtlasPool { final _atlases = {}; - TerminalAtlasHandle acquireAtlas(AtlasConfig config) { + /// Acquires the atlas for [config], creating it when necessary. + /// + /// The caller must release the returned lease when it no longer uses the + /// atlas. + AtlasLease acquireAtlas(AtlasConfig config) { final entry = _atlases.putIfAbsent( config, () => _CachedAtlas(Atlas(config)), ); entry.references++; - return TerminalAtlasHandle._(this, config, entry); + return AtlasLease._(this, config, entry); } + /// Disposes every pooled atlas, including atlases with outstanding leases. void dispose() { for (final entry in _atlases.values) { entry.atlas.dispose(); diff --git a/packages/flterm/lib/src/rendering/terminal_frame_builder.dart b/packages/flterm/lib/src/rendering/frame_builder.dart similarity index 98% rename from packages/flterm/lib/src/rendering/terminal_frame_builder.dart rename to packages/flterm/lib/src/rendering/frame_builder.dart index 3f38a75d..449ce116 100644 --- a/packages/flterm/lib/src/rendering/terminal_frame_builder.dart +++ b/packages/flterm/lib/src/rendering/frame_builder.dart @@ -29,7 +29,7 @@ bool _isOperator(int cp) { } int _resolveColorArgb( - TerminalPaintState state, + PaintState state, CellColor color, { required bool isForeground, int? defaultForeground, @@ -46,7 +46,7 @@ int _resolveColorArgb( } (int foreground, int background) _resolveStyleColors( - TerminalPaintState state, + PaintState state, Style style, { int? defaultForeground, int? defaultBackground, @@ -89,7 +89,7 @@ int _resolveColorArgb( /// Tracks per-row dirtiness from sources outside libghostty's own row-dirty /// flag, such as selection, blink, layout, or atlas changes. /// -/// [TerminalFrameBuilder] combines this with [RowIterator.dirty] when deciding +/// [FrameBuilder] combines this with [RowIterator.dirty] when deciding /// whether to re-emit each row, and clears it at the end of every build. class RowDirtyTracker { var _rows = Uint8List(0); @@ -145,26 +145,26 @@ class RowDirtyTracker { /// built-in sprites, backgrounds, and decorations. The render object owns /// lifecycle/layout/paint ordering; this class owns frame state sync, dirty-row /// buffer generation, cursor visual resolution, and cell-content routing. -class TerminalFrameBuilder { +class FrameBuilder { final Atlas _atlas; + final PaintState _state; final RowIterator _rows; final CellIterator _cells; final SpriteBuffer _sprites; final RenderState _renderState; - final TerminalPaintState _state; final RowDirtyTracker _dirtyRows; final CellContentResolver _content; - late final _TerminalRowBuilder _rowBuilder; + late final _RowBuilder _rowBuilder; late final _CursorFrameBuilder _cursorBuilder; - TerminalFrameBuilder(this._atlas, this._sprites, this._state) + FrameBuilder(this._atlas, this._sprites, this._state) : _content = CellContentResolver(_atlas), _renderState = RenderState(), _rows = RowIterator(), _cells = CellIterator(), _dirtyRows = RowDirtyTracker() { - _rowBuilder = _TerminalRowBuilder( + _rowBuilder = _RowBuilder( atlas: _atlas, sprites: _sprites, state: _state, @@ -314,7 +314,7 @@ final class _CursorCellSnapshot { /// Resolves cursor geometry, colors, and block-cursor glyph state. final class _CursorFrameBuilder { - final TerminalPaintState _state; + final PaintState _state; final CellContentResolver _content; var _cursor = const Cursor(); _CursorCellSnapshot? _lastCell; @@ -439,9 +439,9 @@ final class _CursorFrameBuilder { /// Emits text, emoji, and built-in sprite foreground channels. final class _ForegroundEmitter { + final PaintState _state; final SpriteBuffer _sprites; final _FrameSnapshot _frame; - final TerminalPaintState _state; final CellContentResolver _content; final _AsciiOperatorRun _operators; TerminalTheme? _lastTextStyleTheme; @@ -689,7 +689,7 @@ final class _FrameSnapshot { return (newAlpha << 24) | (argb & 0x00FFFFFF); } - void update(TerminalPaintState state, {required Atlas atlas}) { + void update(PaintState state, {required Atlas atlas}) { final metrics = state.metrics; cellWidth = metrics.cellWidth; cellHeight = metrics.cellHeight; @@ -930,7 +930,7 @@ final class _StyleResolver { // Covers common 256-color fg/bg animation palettes within one frame. static const _maxEntries = 1024; - final TerminalPaintState _state; + final PaintState _state; final Int32List _gen; final Int32List _foreground; final Int32List _background; @@ -1109,10 +1109,10 @@ RgbColor? _rgbColor(Color? color) { } /// Rebuilds dirty rows into background, foreground, and decoration channels. -final class _TerminalRowBuilder { +final class _RowBuilder { final Atlas _atlas; + final PaintState _state; final SpriteBuffer _sprites; - final TerminalPaintState _state; final _FrameSnapshot _frame; final _RowBuildState _row; final _StyleResolver _styles; @@ -1122,7 +1122,7 @@ final class _TerminalRowBuilder { LinkSnapshot linkSnapshot = .empty; var _hasLinks = false; - _TerminalRowBuilder({ + _RowBuilder({ required this._atlas, required this._sprites, required this._state, diff --git a/packages/flterm/lib/src/rendering/terminal_frame_source.dart b/packages/flterm/lib/src/rendering/frame_source.dart similarity index 86% rename from packages/flterm/lib/src/rendering/terminal_frame_source.dart rename to packages/flterm/lib/src/rendering/frame_source.dart index d2cc82b9..8e29b087 100644 --- a/packages/flterm/lib/src/rendering/terminal_frame_source.dart +++ b/packages/flterm/lib/src/rendering/frame_source.dart @@ -7,11 +7,11 @@ import 'package:libghostty/libghostty.dart' hide Listenable; /// state or prepare frames. This keeps [TerminalRenderBox] subscribed to one /// lifecycle-bound object while preserving synchronous invalidation ordering. @internal -final class TerminalFrameSource extends ChangeNotifier { +final class FrameSource extends ChangeNotifier { final Terminal terminal; final Listenable? _viewportChanges; - TerminalFrameSource(this.terminal, {Listenable? viewportChanges}) + FrameSource(this.terminal, {Listenable? viewportChanges}) : _viewportChanges = viewportChanges { terminal.addListener(_handleChanged); viewportChanges?.addListener(_handleChanged); diff --git a/packages/flterm/lib/src/rendering/kitty_placement_cache.dart b/packages/flterm/lib/src/rendering/kitty_placement_cache.dart index 2b79fc68..a0b96628 100644 --- a/packages/flterm/lib/src/rendering/kitty_placement_cache.dart +++ b/packages/flterm/lib/src/rendering/kitty_placement_cache.dart @@ -12,7 +12,7 @@ import 'paint_state.dart'; /// invalidates resolved placement rectangles. Unchanged inputs avoid placement /// iteration, image lookup, sorting, and image eviction. final class KittyPlacementCache { - final TerminalPaintState _state; + final PaintState _state; final KittyImageCache _images; final List _snapshots = []; final Set _liveImageIds = {}; diff --git a/packages/flterm/lib/src/rendering/paint_state.dart b/packages/flterm/lib/src/rendering/paint_state.dart index 2f2f52aa..8084add7 100644 --- a/packages/flterm/lib/src/rendering/paint_state.dart +++ b/packages/flterm/lib/src/rendering/paint_state.dart @@ -14,7 +14,7 @@ import 'atlas/atlas.dart'; /// /// Contains grid dimensions, device pixel ratio, resolved terminal /// colors, cursor state, IME preedit state, and faint text opacity. -class TerminalPaintState { +class PaintState { TerminalTheme theme; CellMetrics metrics; @@ -48,7 +48,7 @@ class TerminalPaintState { /// over the active composing range. var preeditActive = false; - TerminalPaintState(this.theme, this.metrics) + PaintState(this.theme, this.metrics) : faintAlpha = (theme.faintOpacity * 255).ceil() { terminalForegroundArgb = theme.foreground.toARGB32(); terminalBackgroundArgb = theme.background.toARGB32(); diff --git a/packages/flterm/lib/src/rendering/terminal_painter_stack.dart b/packages/flterm/lib/src/rendering/painter_stack.dart similarity index 97% rename from packages/flterm/lib/src/rendering/terminal_painter_stack.dart rename to packages/flterm/lib/src/rendering/painter_stack.dart index c9fe02fc..350a163c 100644 --- a/packages/flterm/lib/src/rendering/terminal_painter_stack.dart +++ b/packages/flterm/lib/src/rendering/painter_stack.dart @@ -18,12 +18,12 @@ import 'painters/terminal_text_painter.dart'; import 'painters/underline_painter.dart'; /// Owns paint helpers, paint order, and paint-only terminal resources. -final class TerminalPainterStack { +final class PainterStack { // The protocol splits negative z values in half at INT32_MIN / 2. static const int _kittyBelowBackgroundThreshold = -1 << 30; + final PaintState _state; final SpriteBuffer _sprites; - final TerminalPaintState _state; final KittyImageCache _kittyImageCache; final List _kittyBelowBackground = []; final List _kittyBelowText = []; @@ -42,7 +42,7 @@ final class TerminalPainterStack { late TerminalTextPainter _textPainter; late UnderlinePainter _underlinePainter; - TerminalPainterStack({ + PainterStack({ required Atlas atlas, required this._sprites, required this._state, diff --git a/packages/flterm/lib/src/rendering/painters/background_painter.dart b/packages/flterm/lib/src/rendering/painters/background_painter.dart index fbb6c7f6..da348534 100644 --- a/packages/flterm/lib/src/rendering/painters/background_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/background_painter.dart @@ -12,17 +12,17 @@ import 'terminal_painter.dart'; /// color and then draws per-cell explicit background rects on top via a /// batched [Canvas.drawVertices] call. /// -/// When [TerminalPaintState.backgroundOpacity] is less than 1.0, skips +/// When [PaintState.backgroundOpacity] is less than 1.0, skips /// the grid fill so the backdrop behind the repaint boundary layer /// shows through on default background cells; filling here would /// composite twice against that backdrop. Per-cell explicit background /// rects still render on top, with alpha scaled by the frame builder when -/// [TerminalPaintState.backgroundOpacityCells] is true. +/// [PaintState.backgroundOpacityCells] is true. class BackgroundPainter implements TerminalPainter { final Paint _fillPaint; final Paint _vertexPaint; final SpriteBuffer _sprites; - final TerminalPaintState _state; + final PaintState _state; BackgroundPainter(this._state, this._sprites) : _fillPaint = Paint(), diff --git a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart index bd88bcbb..f8e8d0c5 100644 --- a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart @@ -25,7 +25,7 @@ import 'terminal_painter.dart'; class CursorPainter implements TerminalPainter { final Paint _paint; final Atlas _atlas; - final TerminalPaintState _state; + final PaintState _state; CursorPainter(this._state, this._atlas) : _paint = Paint(); diff --git a/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart b/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart index 04b8d018..85cea0c9 100644 --- a/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart @@ -13,8 +13,8 @@ import 'terminal_painter.dart'; /// this painter only clips and draws the snapshots it receives. class KittyGraphicsPainter implements TerminalPainter { final Paint _paint; + final PaintState _state; final KittyImageCache _cache; - final TerminalPaintState _state; final List _snapshots; KittyGraphicsPainter({ diff --git a/packages/flterm/lib/src/rendering/painters/terminal_painter.dart b/packages/flterm/lib/src/rendering/painters/terminal_painter.dart index 1405b1b2..e02d049f 100644 --- a/packages/flterm/lib/src/rendering/painters/terminal_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/terminal_painter.dart @@ -7,7 +7,7 @@ import 'dart:ui'; /// (the render box applies the canvas translate before calling [paint]). /// /// Painters are stateless beyond pre-allocated [Paint] objects. Paint data -/// comes from frame buffers such as [TerminalPaintState], [SpriteBuffer], and +/// comes from frame buffers such as [PaintState], [SpriteBuffer], and /// paint-ready layers prepared before painting begins. abstract interface class TerminalPainter { void paint(Canvas canvas); diff --git a/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart b/packages/flterm/lib/src/rendering/render_pipeline.dart similarity index 83% rename from packages/flterm/lib/src/rendering/terminal_render_pipeline.dart rename to packages/flterm/lib/src/rendering/render_pipeline.dart index 7c13a940..81c4e9c3 100644 --- a/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart +++ b/packages/flterm/lib/src/rendering/render_pipeline.dart @@ -5,30 +5,30 @@ import 'package:libghostty/libghostty.dart'; import '../links/link_snapshot.dart'; import 'atlas/atlas.dart'; import 'atlas/sprite_buffer.dart'; +import 'frame_builder.dart'; import 'paint_state.dart'; -import 'terminal_frame_builder.dart'; -import 'terminal_painter_stack.dart'; +import 'painter_stack.dart'; /// Owns the frame buffers, frame builder, and paint stack for one render box. /// /// [TerminalRenderBox] owns widget/render-object lifecycle. This class owns /// the terminal frame pipeline that must be rebound together when the atlas or /// grid changes. -final class TerminalRenderPipeline { - final TerminalPaintState _state; +final class RenderPipeline { + final PaintState _state; final SpriteBuffer _sprites; - late final TerminalPainterStack _painters; - late TerminalFrameBuilder _frameBuilder; + late final PainterStack _painters; + late FrameBuilder _frameBuilder; var _needsTerminalSync = false; - TerminalRenderPipeline({ + RenderPipeline({ required Atlas atlas, - required TerminalPaintState state, + required PaintState state, required void Function() onImageReady, }) : _state = state, _sprites = SpriteBuffer() { - _frameBuilder = TerminalFrameBuilder(atlas, _sprites, _state); - _painters = TerminalPainterStack( + _frameBuilder = FrameBuilder(atlas, _sprites, _state); + _painters = PainterStack( atlas: atlas, state: state, sprites: _sprites, @@ -38,7 +38,7 @@ final class TerminalRenderPipeline { void bindAtlas(Atlas atlas) { final previousBuilder = _frameBuilder; - _frameBuilder = TerminalFrameBuilder(atlas, _sprites, _state); + _frameBuilder = FrameBuilder(atlas, _sprites, _state); if (_state.rows > 0 && _state.cols > 0) { _frameBuilder.configure(_state.rows, _state.cols); _frameBuilder.markAllRowsDirty(); diff --git a/packages/flterm/lib/src/rendering/terminal_renderer.dart b/packages/flterm/lib/src/rendering/terminal_renderer.dart index a9e5949b..2e145090 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -6,10 +6,10 @@ import 'package:meta/meta.dart'; import '../foundation.dart'; import '../links/link_snapshot.dart'; import 'atlas/atlas_config.dart'; +import 'atlas_pool.dart'; +import 'frame_source.dart'; import 'paint_state.dart'; -import 'terminal_frame_source.dart'; -import 'terminal_render_cache.dart'; -import 'terminal_render_pipeline.dart'; +import 'render_pipeline.dart'; /// Renders a terminal screen with cell backgrounds, styled text, cursors, /// and selection overlays. @@ -37,7 +37,7 @@ import 'terminal_render_pipeline.dart'; @internal final class TerminalRenderer extends LeafRenderObjectWidget { /// Supplies the terminal and publishes frame and viewport changes. - final TerminalFrameSource frameSource; + final FrameSource frameSource; /// Visual style applied to the terminal. /// @@ -86,13 +86,13 @@ final class TerminalRenderer extends LeafRenderObjectWidget { /// /// The callback receives the complete measured geometry. The owner must /// apply the transaction before notifying its backend. - final ValueChanged onGeometryChanged; + final ValueChanged onGeometryChanged; /// Device pixel ratio of the Flutter view hosting this renderer. final double devicePixelRatio; - /// Internal render cache used to share compatible atlas state. - final TerminalRenderCache renderCache; + /// Internal atlas pool used to share compatible rendering state. + final AtlasPool atlasPool; /// Requests a terminal viewport row derived from Flutter scroll layout. final ValueChanged onViewportRowChanged; @@ -105,7 +105,7 @@ final class TerminalRenderer extends LeafRenderObjectWidget { this.surfacePadding = EdgeInsets.zero, required this.offset, required this.focused, - required this.renderCache, + required this.atlasPool, this.devicePixelRatio = 1, this.blinkVisible = true, this.preeditText = '', @@ -122,7 +122,7 @@ final class TerminalRenderer extends LeafRenderObjectWidget { metrics: metrics, surfacePadding: surfacePadding, frameSource: frameSource, - renderCache: renderCache, + atlasPool: atlasPool, devicePixelRatio: devicePixelRatio, onGeometryChanged: onGeometryChanged, onViewportRowChanged: onViewportRowChanged, @@ -159,7 +159,7 @@ final class TerminalRenderer extends LeafRenderObjectWidget { renderObject ..frameSource = frameSource ..theme = theme - ..renderCache = renderCache + ..atlasPool = atlasPool ..offset = offset ..metrics = metrics ..surfacePadding = surfacePadding @@ -192,15 +192,15 @@ final class TerminalRenderer extends LeafRenderObjectWidget { /// Created and managed by [TerminalRenderer]. Not intended for direct use. @internal final class TerminalRenderBox extends RenderBox { - final TerminalPaintState _paintState; - late final TerminalRenderPipeline _pipeline; + final PaintState _paintState; + late final RenderPipeline _pipeline; var _applyingViewportIntent = false; var _cellHeightPx = 0; var _cellWidthPx = 0; double _devicePixelRatio; - TerminalFrameSource _frameSource; - late TerminalAtlasHandle _atlasHandle; + FrameSource _frameSource; + late AtlasLease _atlasLease; var _lastCellHeight = 0.0; var _lastCellWidth = 0.0; var _lastDevicePixelRatio = 0.0; @@ -209,13 +209,13 @@ final class TerminalRenderBox extends RenderBox { LinkSnapshot _linkSnapshot; var _needsFrameSync = false; ViewportOffset _offset; - ValueChanged _onGeometryChanged; + ValueChanged _onGeometryChanged; ValueChanged _onViewportRowChanged; int? _pendingViewportRow; var _performingLayout = false; var _preeditText = ''; bool? _primaryStickToBottom; - TerminalRenderCache _renderCache; + AtlasPool _atlasPool; var _stickToBottom = true; var _surfacePadding = EdgeInsets.zero; @@ -226,7 +226,7 @@ final class TerminalRenderBox extends RenderBox { EdgeInsets surfacePadding = EdgeInsets.zero, required this._offset, required bool focused, - required this._renderCache, + required this._atlasPool, required this._devicePixelRatio, bool blinkVisible = true, this._linkSnapshot = .empty, @@ -235,18 +235,18 @@ final class TerminalRenderBox extends RenderBox { required this._onViewportRowChanged, }) : _surfacePadding = surfacePadding, _lastSurfacePadding = surfacePadding, - _paintState = TerminalPaintState(theme, metrics) + _paintState = PaintState(theme, metrics) ..blinkVisible = blinkVisible ..cursorFocused = focused { - _atlasHandle = _renderCache.acquireAtlas( + _atlasLease = _atlasPool.acquireAtlas( .fromTheme( theme: theme, metrics: metrics, devicePixelRatio: _devicePixelRatio, ), ); - final atlas = _atlasHandle.atlas; - _pipeline = TerminalRenderPipeline( + final atlas = _atlasLease.atlas; + _pipeline = RenderPipeline( atlas: atlas, state: _paintState, onImageReady: markNeedsPaint, @@ -350,7 +350,7 @@ final class TerminalRenderBox extends RenderBox { markNeedsLayout(); } - set onGeometryChanged(ValueChanged value) => + set onGeometryChanged(ValueChanged value) => _onGeometryChanged = value; set devicePixelRatio(double value) { @@ -371,15 +371,15 @@ final class TerminalRenderBox extends RenderBox { markNeedsPaint(); } - set renderCache(TerminalRenderCache value) { - if (identical(value, _renderCache)) return; + set atlasPool(AtlasPool value) { + if (identical(value, _atlasPool)) return; - _renderCache = value; + _atlasPool = value; final atlasChanged = _acquireAtlasForCurrentConfig(force: true); if (atlasChanged) _markFrameDirty(); } - set frameSource(TerminalFrameSource value) { + set frameSource(FrameSource value) { if (identical(_frameSource, value)) return; if (attached) _frameSource.removeListener(_onFrameChanged); final terminalChanged = !identical(_terminal, value.terminal); @@ -460,7 +460,7 @@ final class TerminalRenderBox extends RenderBox { _paintState.rows = 0; _paintState.cols = 0; _pipeline.dispose(); - _atlasHandle.release(); + _atlasLease.release(); super.dispose(); } @@ -521,7 +521,7 @@ final class TerminalRenderBox extends RenderBox { if (newCols > 0 && newRows > 0) { if (gridChanged) _pipeline.configureGrid(newRows, newCols); _onGeometryChanged( - TerminalResizeEvent( + SurfaceMeasurement( cols: newCols, rows: newRows, cellWidth: _paintState.metrics.cellWidth, @@ -560,12 +560,12 @@ final class TerminalRenderBox extends RenderBox { metrics: _paintState.metrics, devicePixelRatio: dpr ?? _devicePixelRatio, ); - if (!force && config == _atlasHandle.config) return false; + if (!force && config == _atlasLease.config) return false; - final previousHandle = _atlasHandle; - _atlasHandle = _renderCache.acquireAtlas(config); - _pipeline.bindAtlas(_atlasHandle.atlas); - previousHandle.release(); + final previousLease = _atlasLease; + _atlasLease = _atlasPool.acquireAtlas(config); + _pipeline.bindAtlas(_atlasLease.atlas); + previousLease.release(); return true; } diff --git a/packages/flterm/lib/src/view/terminal_cursor_blink.dart b/packages/flterm/lib/src/view/cursor_blink.dart similarity index 86% rename from packages/flterm/lib/src/view/terminal_cursor_blink.dart rename to packages/flterm/lib/src/view/cursor_blink.dart index a1a0ad47..485953aa 100644 --- a/packages/flterm/lib/src/view/terminal_cursor_blink.dart +++ b/packages/flterm/lib/src/view/cursor_blink.dart @@ -8,20 +8,20 @@ import 'package:flutter/foundation.dart'; /// position, or theme timing changes. Disabling blinking always restores the /// visible phase so a paused cursor cannot remain hidden. @internal -final class TerminalCursorBlink extends ValueNotifier { +final class CursorBlink extends ValueNotifier { Timer? _timer; - TerminalCursorBlink() : super(true); - - void sync({required bool enabled, required Duration interval}) { - _timer?.cancel(); - _timer = enabled ? Timer.periodic(interval, (_) => value = !value) : null; - if (!value) value = true; - } + CursorBlink() : super(true); @override void dispose() { _timer?.cancel(); super.dispose(); } + + void sync({required bool enabled, required Duration interval}) { + _timer?.cancel(); + _timer = enabled ? Timer.periodic(interval, (_) => value = !value) : null; + if (!value) value = true; + } } diff --git a/packages/flterm/lib/src/view/terminal_shortcut_scope.dart b/packages/flterm/lib/src/view/shortcut_scope.dart similarity index 94% rename from packages/flterm/lib/src/view/terminal_shortcut_scope.dart rename to packages/flterm/lib/src/view/shortcut_scope.dart index 16ed9238..ad8bd79d 100644 --- a/packages/flterm/lib/src/view/terminal_shortcut_scope.dart +++ b/packages/flterm/lib/src/view/shortcut_scope.dart @@ -32,7 +32,7 @@ final class SelectAllIntent extends Intent { /// Platform-adaptive default shortcut bindings for terminal actions. @internal -abstract final class TerminalShortcuts { +abstract final class DefaultShortcuts { static Map defaultsFor([ TargetPlatform? platform, ]) { @@ -71,14 +71,14 @@ abstract final class TerminalShortcuts { /// terminal when no selection is present. /// /// ```dart -/// TerminalShortcutScope( +/// ShortcutScope( /// controller: controller, /// onPaste: handlePaste, /// child: terminalContent, /// ) /// ``` @internal -final class TerminalShortcutScope extends StatelessWidget { +final class ShortcutScope extends StatelessWidget { final Widget child; final VoidCallback? onPaste; final TerminalController controller; @@ -89,7 +89,7 @@ final class TerminalShortcutScope extends StatelessWidget { /// Additional shortcut bindings merged over platform defaults. final Map? shortcuts; - const TerminalShortcutScope({ + const ShortcutScope({ super.key, required this.child, required this.controller, @@ -101,7 +101,7 @@ final class TerminalShortcutScope extends StatelessWidget { @override Widget build(BuildContext context) { return Shortcuts( - shortcuts: {...TerminalShortcuts.defaultsFor(), ...?shortcuts}, + shortcuts: {...DefaultShortcuts.defaultsFor(), ...?shortcuts}, child: Actions( actions: >{ CopyIntent: _ConditionalAction( diff --git a/packages/flterm/lib/src/view/terminal_scope.dart b/packages/flterm/lib/src/view/terminal_scope.dart index e6789cd7..7cb7a79f 100644 --- a/packages/flterm/lib/src/view/terminal_scope.dart +++ b/packages/flterm/lib/src/view/terminal_scope.dart @@ -1,18 +1,18 @@ import 'package:flutter/widgets.dart'; -import '../rendering/terminal_render_cache.dart'; +import '../rendering/atlas_pool.dart'; -/// Returns the shared terminal render cache nearest to [context], if any. -TerminalRenderCache? terminalScopeRenderCacheOf(BuildContext context) { +/// Returns the shared terminal atlas pool nearest to [context], if any. +AtlasPool? terminalScopeAtlasPoolOf(BuildContext context) { return context .dependOnInheritedWidgetOfExactType<_TerminalScopeInherited>() - ?.renderCache; + ?.atlasPool; } /// Shares terminal resources across descendant [TerminalView] widgets. /// /// Wrapping multiple terminals in the same scope lets compatible renderers -/// reuse expensive internal caches. Terminals outside a scope create an +/// reuse compatible glyph atlases. Terminals outside a scope create an /// isolated local scope automatically. class TerminalScope extends StatefulWidget { final Widget child; @@ -24,33 +24,30 @@ class TerminalScope extends StatefulWidget { } final class _TerminalScopeState extends State { - final _renderCache = TerminalRenderCache(); + final _atlasPool = AtlasPool(); @override Widget build(BuildContext context) { - return _TerminalScopeInherited( - renderCache: _renderCache, - child: widget.child, - ); + return _TerminalScopeInherited(atlasPool: _atlasPool, child: widget.child); } @override void dispose() { - _renderCache.dispose(); + _atlasPool.dispose(); super.dispose(); } } final class _TerminalScopeInherited extends InheritedWidget { - final TerminalRenderCache renderCache; + final AtlasPool atlasPool; const _TerminalScopeInherited({ - required this.renderCache, + required this.atlasPool, required super.child, }); @override bool updateShouldNotify(_TerminalScopeInherited oldWidget) { - return !identical(renderCache, oldWidget.renderCache); + return !identical(atlasPool, oldWidget.atlasPool); } } diff --git a/packages/flterm/lib/src/view/terminal_scroll_controller.dart b/packages/flterm/lib/src/view/terminal_scroll_controller.dart index fda64629..b9093ae9 100644 --- a/packages/flterm/lib/src/view/terminal_scroll_controller.dart +++ b/packages/flterm/lib/src/view/terminal_scroll_controller.dart @@ -35,7 +35,7 @@ class TerminalScrollController extends ScrollController { if (_activeScreen == value) return; _activeScreen = value; for (final position in positions) { - (position as TerminalScrollPosition).activeScreen = value; + (position as ScrollbackPosition).activeScreen = value; } } @@ -45,7 +45,7 @@ class TerminalScrollController extends ScrollController { ScrollContext context, ScrollPosition? oldPosition, ) { - return TerminalScrollPosition( + return ScrollbackPosition( physics: physics, context: context, oldPosition: oldPosition, @@ -59,11 +59,11 @@ class TerminalScrollController extends ScrollController { /// Alternate screens expose unbounded extents because touch and wheel input is /// routed to terminal applications rather than moving the Flutter viewport. @internal -final class TerminalScrollPosition extends ScrollPositionWithSingleContext { +final class ScrollbackPosition extends ScrollPositionWithSingleContext { double? _savedPixels; TerminalScreen _activeScreen; - TerminalScrollPosition({ + ScrollbackPosition({ required super.physics, required super.context, required this._activeScreen, diff --git a/packages/flterm/lib/src/view/terminal_view.dart b/packages/flterm/lib/src/view/terminal_view.dart index bb15134c..52aedfbc 100644 --- a/packages/flterm/lib/src/view/terminal_view.dart +++ b/packages/flterm/lib/src/view/terminal_view.dart @@ -5,16 +5,16 @@ import 'package:flutter/widgets.dart'; import '../controller/terminal_controller.dart'; import '../foundation.dart'; -import '../input/terminal_gesture_detector.dart'; +import '../input/interaction_region.dart'; import '../links/link_interaction.dart'; import '../links/link_settings.dart'; import '../rendering.dart'; -import '../rendering/terminal_render_cache.dart'; -import 'terminal_cursor_blink.dart'; +import '../rendering/atlas_pool.dart'; +import 'cursor_blink.dart'; +import 'shortcut_scope.dart'; import 'terminal_scope.dart'; import 'terminal_scroll_controller.dart'; -import 'terminal_shortcut_scope.dart'; -import 'terminal_view_attachment.dart'; +import 'view_attachment.dart'; /// Displays a terminal and handles user interaction. /// @@ -136,14 +136,15 @@ class TerminalView extends StatefulWidget { State createState() => _TerminalViewState(); } -final class _TerminalViewState extends State { +final class _TerminalViewState extends State + with WidgetsBindingObserver { final _rendererKey = GlobalKey(); final _links = LinkInteraction(); - final _cursorBlink = TerminalCursorBlink(); + final _cursorBlink = CursorBlink(); final _mouseCursorHidden = ValueNotifier(false); late final _mouseInteraction = Listenable.merge([_links, _mouseCursorHidden]); - late TerminalViewAttachment _attachment; + late ViewAttachment _attachment; var _devicePixelRatio = 1.0; late FocusNode _focusNode; late ScrollPhysics _gestureScrollPhysics; @@ -159,12 +160,12 @@ final class _TerminalViewState extends State { @override Widget build(BuildContext context) { - final cache = terminalScopeRenderCacheOf(context); - if (cache != null) return _build(cache); + final atlasPool = terminalScopeAtlasPoolOf(context); + if (atlasPool != null) return _build(atlasPool); return TerminalScope( child: Builder( - builder: (context) => _build(terminalScopeRenderCacheOf(context)!), + builder: (context) => _build(terminalScopeAtlasPoolOf(context)!), ), ); } @@ -182,13 +183,25 @@ final class _TerminalViewState extends State { final devicePixelRatio = View.of(context).devicePixelRatio; if (_devicePixelRatio == devicePixelRatio) return; - _devicePixelRatio = devicePixelRatio; _metrics = _measureMetrics(); _links.cancel(); _syncLinkInteraction(); } + @override + void didChangeMetrics() { + if (!mounted) return; + final devicePixelRatio = View.of(context).devicePixelRatio; + if (_devicePixelRatio == devicePixelRatio) return; + setState(() { + _devicePixelRatio = devicePixelRatio; + _metrics = _measureMetrics(); + }); + _links.cancel(); + _syncLinkInteraction(); + } + @override void didUpdateWidget(TerminalView oldWidget) { super.didUpdateWidget(oldWidget); @@ -224,7 +237,7 @@ final class _TerminalViewState extends State { } if (controllerChanged) { - _attachment = TerminalViewAttachment(_controller); + _attachment = ViewAttachment(_controller); _attachment.addListener(_onControllerChanged); _links.invalidateContent(); } @@ -271,6 +284,7 @@ final class _TerminalViewState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _cursorBlink.dispose(); _mouseCursorHidden.dispose(); _links.dispose(); @@ -285,9 +299,9 @@ final class _TerminalViewState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); - _attachment = TerminalViewAttachment(_controller); - + _attachment = ViewAttachment(_controller); _focusNode = widget.focusNode ?? FocusNode(); _ownsFocusNode = widget.focusNode == null; @@ -295,20 +309,17 @@ final class _TerminalViewState extends State { _attachment.applyTheme(_theme); _metrics = _measureMetrics(); - if (widget.fontData == null) { - unawaited(_resolveFontData(_theme.fontFamily)); - } + if (widget.fontData == null) unawaited(_resolveFontData(_theme.fontFamily)); _scrollController = widget.scrollController ?? TerminalScrollController(); _ownsScrollController = widget.scrollController == null; _scrollController.activeScreen = _controller.activeScreen; _scrollController.addListener(_onScrollChanged); - _attachment.addListener(_onControllerChanged); _syncLinkInteraction(); } - Widget _build(TerminalRenderCache cache) { + Widget _build(AtlasPool atlasPool) { return GestureDetector( behavior: .translucent, onTap: _attachment.requestFocus, @@ -318,7 +329,7 @@ final class _TerminalViewState extends State { padding: widget.padding, child: Focus( onKeyEvent: _handleKeyEvent, - child: TerminalShortcutScope( + child: ShortcutScope( onPaste: _handlePaste, controller: _controller, shortcuts: widget.shortcuts, @@ -326,7 +337,7 @@ final class _TerminalViewState extends State { child: ListenableBuilder( listenable: _attachment.interaction, builder: (_, _) => - _buildInteraction(cache, _gestureScrollPhysics), + _buildInteraction(atlasPool, _gestureScrollPhysics), ), ), ), @@ -336,7 +347,7 @@ final class _TerminalViewState extends State { } Widget _buildInteraction( - TerminalRenderCache renderCache, + AtlasPool atlasPool, ScrollPhysics gestureScrollPhysics, ) { final interaction = _attachment.interaction.value; @@ -350,7 +361,7 @@ final class _TerminalViewState extends State { child: Scrollable( controller: _scrollController, physics: scrollPhysics, - viewportBuilder: (_, offset) => TerminalGestureDetector( + viewportBuilder: (_, offset) => InteractionRegion( links: _links, metrics: _metrics, attachment: _attachment, @@ -373,7 +384,7 @@ final class _TerminalViewState extends State { metrics: _metrics, focused: _focusNode.hasFocus, frameSource: _attachment.frameSource, - renderCache: renderCache, + atlasPool: atlasPool, surfacePadding: widget.padding, devicePixelRatio: _devicePixelRatio, preeditText: _attachment.input.preeditText, @@ -446,8 +457,8 @@ final class _TerminalViewState extends State { _controller.paste(data.text!); } - void _handleResize(TerminalResizeEvent event) { - _attachment.handleResize(event); + void _handleResize(SurfaceMeasurement measurement) { + _attachment.handleResize(measurement); _syncLinkInteraction(); } @@ -500,13 +511,6 @@ final class _TerminalViewState extends State { setState(() {}); } - void _updateGestureScrollPhysics() { - final behavior = ScrollConfiguration.of(context); - final defaultPhysics = behavior.getScrollPhysics(context); - _gestureScrollPhysics = - widget.scrollPhysics?.applyTo(defaultPhysics) ?? defaultPhysics; - } - void _syncBlink({bool? focused}) { _cursorBlink.sync( enabled: @@ -539,6 +543,13 @@ final class _TerminalViewState extends State { ); } + void _updateGestureScrollPhysics() { + final behavior = ScrollConfiguration.of(context); + final defaultPhysics = behavior.getScrollPhysics(context); + _gestureScrollPhysics = + widget.scrollPhysics?.applyTo(defaultPhysics) ?? defaultPhysics; + } + void _updateTextInputGeometry() { final renderObject = _rendererKey.currentContext?.findRenderObject(); if (renderObject is! TerminalRenderBox || diff --git a/packages/flterm/lib/src/view/terminal_view_attachment.dart b/packages/flterm/lib/src/view/view_attachment.dart similarity index 77% rename from packages/flterm/lib/src/view/terminal_view_attachment.dart rename to packages/flterm/lib/src/view/view_attachment.dart index bf6b79ed..1466b3f6 100644 --- a/packages/flterm/lib/src/view/terminal_view_attachment.dart +++ b/packages/flterm/lib/src/view/view_attachment.dart @@ -5,10 +5,10 @@ import 'package:libghostty/libghostty.dart' hide Listenable; import '../controller/terminal_controller.dart'; import '../foundation.dart'; -import '../input/terminal_input_adapter.dart'; -import '../input/terminal_input_event.dart'; -import '../interaction/terminal_selection.dart'; -import '../rendering/terminal_frame_source.dart'; +import '../input/input_message.dart'; +import '../input/keyboard_input_adapter.dart'; +import '../interaction/selection_session.dart'; +import '../rendering/frame_source.dart'; import 'compression_scheduler.dart'; /// Terminal modes that must be observed atomically by gesture routing. @@ -17,17 +17,21 @@ import 'compression_scheduler.dart'; /// mouse mode, and alternate-scroll flag sampled from different terminal /// notifications. @immutable -final class TerminalInteractionState { - /// The active primary or alternate terminal screen. +@internal +final class ViewInteractionState { + /// The active primary or alternate screen governing the interaction. final TerminalScreen activeScreen; - /// The terminal's active mouse-reporting mode. + /// The active mouse tracking mode requested by terminal content. final MouseTracking mouseTracking; - /// Whether alternate-screen scrolling is enabled. + /// Whether DEC alternate-scroll mode is enabled. + /// + /// While the alternate screen is active, this mode maps wheel input to + /// cursor keys. final bool alternateScroll; - const TerminalInteractionState({ + const ViewInteractionState({ required this.activeScreen, required this.mouseTracking, required this.alternateScroll, @@ -37,12 +41,11 @@ final class TerminalInteractionState { int get hashCode => Object.hash(activeScreen, mouseTracking, alternateScroll); @override - bool operator ==(Object other) { - return other is TerminalInteractionState && - other.activeScreen == activeScreen && - other.mouseTracking == mouseTracking && - other.alternateScroll == alternateScroll; - } + bool operator ==(Object other) => + other is ViewInteractionState && + other.activeScreen == activeScreen && + other.mouseTracking == mouseTracking && + other.alternateScroll == alternateScroll; } /// Owns one Flutter view's attachment to a terminal controller. @@ -52,23 +55,23 @@ final class TerminalInteractionState { /// compression, theme reporting, and normalized event routing. Disposing it /// releases the controller's single-view lease and every listener it created. @internal -final class TerminalViewAttachment extends ChangeNotifier { +final class ViewAttachment extends ChangeNotifier { final Object _viewToken; - final TerminalInputAdapter input; + final KeyboardInputAdapter input; final TerminalControllerImpl _controller; - late final TerminalFrameSource frameSource; + late final FrameSource frameSource; late final CompressionScheduler _compressionScheduler; - late final ValueNotifier _interaction; + late final ValueNotifier _interaction; ScrollController? _scrollController; var _disposed = false; - factory TerminalViewAttachment(TerminalController controller) => - TerminalViewAttachment._(controller as TerminalControllerImpl); + factory ViewAttachment(TerminalController controller) => + ViewAttachment._(controller as TerminalControllerImpl); - TerminalViewAttachment._(this._controller) + ViewAttachment._(this._controller) : _viewToken = _controller.attachView(), - input = TerminalInputAdapter(_controller) { - frameSource = TerminalFrameSource( + input = KeyboardInputAdapter(_controller) { + frameSource = FrameSource( terminal, viewportChanges: _controller.viewportChanges, ); @@ -96,7 +99,7 @@ final class TerminalViewAttachment extends ChangeNotifier { return position.pixels >= position.maxScrollExtent - 1.0; } - ValueListenable get interaction => _interaction; + ValueListenable get interaction => _interaction; MouseTracking get mouseTracking => _controller.mouseTracking; @@ -164,19 +167,19 @@ final class TerminalViewAttachment extends ChangeNotifier { super.dispose(); } - void handleMouseEvent(TerminalMouseEvent event) => + void handleMouseEvent(MouseInput event) => _controller.handleMouseEvent(event); - void handleResize(TerminalResizeEvent event) => - _controller.handleResize(event); + void handleResize(SurfaceMeasurement measurement) => + _controller.handleResize(measurement); - void handleSelectionPress(TerminalSelectionPressEvent event) => + void handleSelectionPress(SelectionPressInput event) => _controller.handleSelectionPress(event); void handleSelectionRelease(Position cell) => _controller.handleSelectionRelease(cell); - void handleTerminalScroll(TerminalScrollEvent event) => + void handleTerminalScroll(ScrollInput event) => _controller.handleTerminalScroll(event); void handleViewportRowChanged(int row) { @@ -188,12 +191,12 @@ final class TerminalViewAttachment extends ChangeNotifier { void requestFocus() => input.requestFocus(); - void updateSelectionAutoscroll(TerminalSelectionAutoscrollEvent event) { + void updateSelectionAutoscroll(SelectionAutoscrollInput event) { _controller.updateSelectionAutoscroll(event); _compressionScheduler.notifyActivity(); } - void updateSelectionDrag(TerminalSelectionDragEvent event) => + void updateSelectionDrag(SelectionDragInput event) => _controller.updateSelectionDrag(event); void _handleControllerChanged() { @@ -204,9 +207,9 @@ final class TerminalViewAttachment extends ChangeNotifier { void _handleTerminalChanged() => _compressionScheduler.notifyActivity(); - TerminalInteractionState _readInteractionState() { + ViewInteractionState _readInteractionState() { final activeScreen = _controller.activeScreen; - return TerminalInteractionState( + return ViewInteractionState( activeScreen: activeScreen, mouseTracking: _controller.mouseTracking, alternateScroll: terminal.modeGet(const .alternateScroll()), diff --git a/packages/flterm/test/controller/terminal_controller_test.dart b/packages/flterm/test/controller/terminal_controller_test.dart index 3af3a2cb..19b6d150 100644 --- a/packages/flterm/test/controller/terminal_controller_test.dart +++ b/packages/flterm/test/controller/terminal_controller_test.dart @@ -5,7 +5,8 @@ import 'dart:convert'; import 'package:flterm/src/controller/terminal_controller.dart'; import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/input/terminal_input_event.dart'; +import 'package:flterm/src/input/input_message.dart'; +import 'package:flterm/src/interaction/selection_session.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide KeyEvent; @@ -31,10 +32,6 @@ void main() { controller.write(Uint8List.fromList(utf8.encode(text))); } - TerminalControllerImpl session(TerminalControllerImpl controller) { - return controller; - } - void writeTerminalUtf8(Terminal terminal, String text) { terminal.write(Uint8List.fromList(utf8.encode(text))); } @@ -45,8 +42,8 @@ void main() { double devicePixelRatio = 1.0, }) { writeControllerUtf8(target, sequence); - session(target).handleResize( - TerminalResizeEvent( + target.handleResize( + SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -62,17 +59,17 @@ void main() { group('constructor', () { test('exposes terminal state without a view attachment', () { - expect(session(controller).terminal, isA()); + expect(controller.terminal, isA()); }); - test('starts without a selection', () { + test('starts without selection or selected text', () { expect(controller.hasSelection, isFalse); expect(controller.selectedText(), ''); }); }); group('geometry', () { - test('does not notify a resize callback before view geometry exists', () { + test('does not notify a resize observer before view geometry exists', () { final sizes = <({int cols, int rows})>[]; controller.onResize = (cols, rows) { @@ -88,8 +85,8 @@ void main() { sizes.add((cols: cols, rows: rows)); }; - session(controller).handleResize( - const TerminalResizeEvent( + controller.handleResize( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -106,10 +103,10 @@ void main() { }); test( - 'reports committed grid when a callback is assigned after measurement', + 'reports committed grid when observer is assigned after measurement', () { - session(controller).handleResize( - const TerminalResizeEvent( + controller.handleResize( + const SurfaceMeasurement( cols: 100, rows: 40, cellWidth: 8, @@ -131,16 +128,16 @@ void main() { }, ); - test('allows output from a resize callback after geometry commits', () { - final binding = session(controller); + test('resize callback observes committed physical geometry', () { + final binding = controller; final output = []; controller.onOutput = output.add; controller.onResize = (_, _) { - controller.sendText('ready'); + controller.write(Uint8List.fromList(utf8.encode('\x1b[14t'))); }; binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -153,7 +150,7 @@ void main() { ), ); - expect(utf8.decode(output.single), 'ready'); + expect(utf8.decode(output.single), '\x1b[4;384;640t'); }); test('answers size queries without consuming render dirtiness', () { @@ -163,7 +160,7 @@ void main() { controller.write(Uint8List.fromList(utf8.encode('hello'))); controller.write(Uint8List.fromList(utf8.encode('\x1b[18t'))); - renderState.update(session(controller).terminal); + renderState.update(controller.terminal); expect(renderState.dirty, isNot(DirtyState.clean)); }); @@ -182,8 +179,8 @@ void main() { }); test('applies physical geometry through the resize event', () { - session(controller).handleResize( - const TerminalResizeEvent( + controller.handleResize( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -196,7 +193,7 @@ void main() { ), ); - expect(session(controller).terminal.geometry, ( + expect(controller.terminal.geometry, ( cols: 80, rows: 24, widthPx: 1280, @@ -205,9 +202,9 @@ void main() { }); test('updates physical geometry when the grid is unchanged', () { - final binding = session(controller); + final binding = controller; binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -220,7 +217,7 @@ void main() { ), ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 10, @@ -242,9 +239,9 @@ void main() { }); test('ignores resize events with invalid physical geometry', () { - final binding = session(controller); + final binding = controller; binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -258,7 +255,7 @@ void main() { ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 100, rows: 30, cellWidth: 0, @@ -280,10 +277,10 @@ void main() { }); test('ignores resize events beyond the native grid limit', () { - final binding = session(controller); + final binding = controller; binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -297,7 +294,7 @@ void main() { ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 65536, rows: 24, cellWidth: 8, @@ -319,7 +316,7 @@ void main() { }); test('emits the measured in-band resize report', () { - final binding = session(controller); + final binding = controller; final output = []; controller.onOutput = output.add; binding.terminal.modeSet( @@ -328,7 +325,7 @@ void main() { ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -344,8 +341,88 @@ void main() { expect(utf8.decode(output.single), '\x1B[48;24;80;384;640t'); }); + test('resize callback observes committed mouse geometry', () { + writeControllerUtf8(controller, '\x1b[?1000h\x1b[?1016h'); + final output = []; + controller.onOutput = output.add; + controller.onResize = (_, _) { + controller.handleMouseEvent( + const MouseInput( + action: .press, + anyButtonPressed: true, + button: .left, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + }; + + controller.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 2, + ), + ); + + expect(utf8.decode(output.single), '\x1b[<0;8;16M'); + }); + + test('resize callback observes committed selection geometry', () { + controller.write(Uint8List.fromList(utf8.encode('hello'))); + var selected = false; + controller.onResize = (_, _) { + controller.handleSelectionPress( + const SelectionPressInput( + cell: Position(row: 0, col: 1), + pixelX: 8, + pixelY: 0, + behaviors: SelectionGestureBehaviors.standard, + wordBoundaries: null, + repeatDistance: 18, + repeatInterval: Duration(milliseconds: 300), + timeStamp: Duration.zero, + fullWidthLine: false, + ), + ); + controller.updateSelectionDrag( + const SelectionDragInput( + cell: Position(row: 0, col: 2), + pixelX: 16, + pixelY: 0, + rectangle: false, + ), + ); + controller.handleSelectionRelease(const Position(row: 0, col: 1)); + selected = controller.hasSelection; + }; + + controller.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(selected, isTrue); + }); + test('emits terminal resize output before the backend callback', () { - final binding = session(controller); + final binding = controller; final events = []; controller.onResize = (_, _) => events.add('resize'); events.clear(); @@ -356,7 +433,7 @@ void main() { ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 81, rows: 24, cellWidth: 8, @@ -373,7 +450,7 @@ void main() { }); test('allows backend output during an in-band resize report', () { - final binding = session(controller); + final binding = controller; var replied = false; controller.onOutput = (_) { replied = true; @@ -385,7 +462,7 @@ void main() { ); binding.handleResize( - const TerminalResizeEvent( + const SurfaceMeasurement( cols: 81, rows: 24, cellWidth: 8, @@ -408,8 +485,8 @@ void main() { final output = []; controller.onOutput = output.add; - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .press, anyButtonPressed: true, button: .right, @@ -418,8 +495,8 @@ void main() { pixelY: 8, ), ); - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .motion, anyButtonPressed: false, button: null, @@ -438,8 +515,8 @@ void main() { final output = []; controller.onOutput = output.add; - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .motion, anyButtonPressed: false, button: .left, @@ -449,8 +526,8 @@ void main() { ), ); - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .motion, anyButtonPressed: true, button: .left, @@ -472,8 +549,8 @@ void main() { final output = []; controller.onOutput = output.add; - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .press, anyButtonPressed: true, button: .left, @@ -490,8 +567,8 @@ void main() { 'maps terminal-local pointer coordinates through surface padding', () { enableMouseTracking(controller, sequence: '\x1b[?1000h\x1b[?1016h'); - session(controller).handleResize( - const TerminalResizeEvent( + controller.handleResize( + const SurfaceMeasurement( cols: 80, rows: 24, cellWidth: 8, @@ -506,8 +583,8 @@ void main() { final output = []; controller.onOutput = output.add; - session(controller).handleMouseEvent( - const TerminalMouseEvent( + controller.handleMouseEvent( + const MouseInput( action: .press, anyButtonPressed: true, button: .left, @@ -525,14 +602,14 @@ void main() { group('handleTerminalScroll', () { test('uses the last pointer position for tracked scroll', () { enableMouseTracking(controller); - session( - controller, - ).terminal.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + controller.terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h')), + ); final output = []; controller.onOutput = output.add; - session(controller).handleTerminalScroll( - const TerminalScrollEvent( + controller.handleTerminalScroll( + const ScrollInput( horizontal: 0, mods: Mods.none(), pixelX: 24, @@ -550,8 +627,8 @@ void main() { final output = []; controller.onOutput = output.add; - session(controller).handleTerminalScroll( - const TerminalScrollEvent( + controller.handleTerminalScroll( + const ScrollInput( horizontal: 0, mods: Mods.none(), pixelX: 24, @@ -567,14 +644,14 @@ void main() { test( 'does not simulate cursor keys when alternate scroll is disabled', () { - session(controller).terminal.write( + controller.terminal.write( Uint8List.fromList(utf8.encode('\x1b[?1049h\x1b[?1007l')), ); final output = []; controller.onOutput = output.add; - session(controller).handleTerminalScroll( - const TerminalScrollEvent( + controller.handleTerminalScroll( + const ScrollInput( horizontal: 0, mods: Mods.none(), pixelX: 24, @@ -589,15 +666,15 @@ void main() { ); test('does not simulate cursor keys while mouse tracking is active', () { - session( - controller, - ).terminal.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + controller.terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h')), + ); enableMouseTracking(controller); final output = []; controller.onOutput = output.add; - session(controller).handleTerminalScroll( - const TerminalScrollEvent( + controller.handleTerminalScroll( + const ScrollInput( horizontal: 0, mods: Mods.none(), pixelX: 24, @@ -852,8 +929,8 @@ void main() { int scrollBack(TerminalControllerImpl target) { writeNumberedLines(target); - session(target).terminal.scrollViewport(-5); - return session(target).terminal.scrollbar.offset; + target.terminal.scrollViewport(-5); + return target.terminal.scrollbar.offset; } test('scrolls to bottom on output when output follow is enabled', () { @@ -863,10 +940,7 @@ void main() { writeControllerUtf8(custom, 'tail\r\n'); - expect( - session(custom).terminal.scrollbar.offset, - custom.scrollbackRows, - ); + expect(custom.terminal.scrollbar.offset, custom.scrollbackRows); }); test( @@ -881,7 +955,7 @@ void main() { end: const Position(row: 0, col: 4), ); - expect(session(custom).terminal.scrollbar.offset, offset); + expect(custom.terminal.scrollbar.offset, offset); }, ); @@ -894,13 +968,13 @@ void main() { start: const Position(row: 0, col: 0), end: const Position(row: 0, col: 4), ); - session(custom).terminal.scrollViewport(-5); - final offset = session(custom).terminal.scrollbar.offset; + custom.terminal.scrollViewport(-5); + final offset = custom.terminal.scrollbar.offset; expect(offset, lessThan(custom.scrollbackRows)); custom.clearSelection(); - expect(session(custom).terminal.scrollbar.offset, offset); + expect(custom.terminal.scrollbar.offset, offset); }, ); @@ -909,8 +983,8 @@ void main() { final offset = scrollBack(custom); expect(offset, lessThan(custom.scrollbackRows)); - session(custom).handleResize( - const TerminalResizeEvent( + custom.handleResize( + const SurfaceMeasurement( cols: 20, rows: 3, cellWidth: 8, @@ -923,7 +997,7 @@ void main() { ), ); - expect(session(custom).terminal.scrollbar.offset, offset); + expect(custom.terminal.scrollbar.offset, offset); }); test('preserves viewport when a terminal mode changes', () { @@ -933,7 +1007,7 @@ void main() { custom.modeSet(const .bracketedPaste(), value: true); - expect(session(custom).terminal.scrollbar.offset, offset); + expect(custom.terminal.scrollbar.offset, offset); }); }); @@ -1189,9 +1263,10 @@ void main() { }); test('wraps with bracketed paste escape when mode is active', () { - session( - controller, - ).terminal.modeSet(const TerminalMode.bracketedPaste(), value: true); + controller.terminal.modeSet( + const TerminalMode.bracketedPaste(), + value: true, + ); final output = []; controller.onOutput = output.add; @@ -1249,15 +1324,15 @@ void main() { addTearDown(custom.dispose); addTearDown(renderState.dispose); - expect(session(custom).terminal.scrollbackMaxBytes, 1024); - expect(session(custom).terminal.scrollbackMaxLines, 10); + expect(custom.terminal.scrollbackMaxBytes, 1024); + expect(custom.terminal.scrollbackMaxLines, 10); custom.write(transmitRedPixel(id: 91)); - expect(KittyGraphics.of(session(custom).terminal)!.image(91), isNull); + expect(KittyGraphics.of(custom.terminal)!.image(91), isNull); - writeTerminalUtf8(session(custom).terminal, '\x1b[0 q'); - renderState.update(session(custom).terminal); + writeTerminalUtf8(custom.terminal, '\x1b[0 q'); + renderState.update(custom.terminal); expect(renderState.cursor.shape, CursorShape.underline); expect(renderState.cursor.blinking, isTrue); @@ -1269,8 +1344,8 @@ void main() { scrollbackMaxLines: 20, ); - expect(session(controller).terminal.scrollbackMaxBytes, 2048); - expect(session(controller).terminal.scrollbackMaxLines, 20); + expect(controller.terminal.scrollbackMaxBytes, 2048); + expect(controller.terminal.scrollbackMaxLines, 20); }); test('setter applies APC buffer limits', () { @@ -1278,10 +1353,7 @@ void main() { controller.write(transmitRedPixel(id: 92)); - expect( - KittyGraphics.of(session(controller).terminal)!.image(92), - isNull, - ); + expect(KittyGraphics.of(controller.terminal)!.image(92), isNull); }); test('setter applies cursor reset defaults', () { @@ -1292,8 +1364,8 @@ void main() { cursorStyle: CursorShape.bar, cursorBlink: false, ); - writeTerminalUtf8(session(controller).terminal, '\x1b[0 q'); - renderState.update(session(controller).terminal); + writeTerminalUtf8(controller.terminal, '\x1b[0 q'); + renderState.update(controller.terminal); expect(renderState.cursor.shape, CursorShape.bar); expect(renderState.cursor.blinking, isFalse); @@ -1316,7 +1388,7 @@ void main() { }); test('switches to alternate via escape sequence', () { - writeTerminalUtf8(session(controller).terminal, '\x1b[?1049h'); + writeTerminalUtf8(controller.terminal, '\x1b[?1049h'); expect(controller.activeScreen, TerminalScreen.alternate); }); }); @@ -1327,10 +1399,7 @@ void main() { }); test('updates via OSC 0 escape sequence', () { - writeTerminalUtf8( - session(controller).terminal, - '\x1b]0;my title\x1b\\', - ); + writeTerminalUtf8(controller.terminal, '\x1b]0;my title\x1b\\'); expect(controller.title, 'my title'); }); @@ -1338,10 +1407,7 @@ void main() { var fired = false; controller.onTitleChanged = () => fired = true; - writeTerminalUtf8( - session(controller).terminal, - '\x1b]0;new title\x1b\\', - ); + writeTerminalUtf8(controller.terminal, '\x1b]0;new title\x1b\\'); expect(fired, isTrue); }); @@ -1349,10 +1415,7 @@ void main() { group('pwd', () { test('updates via OSC 7 escape sequence', () { - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(controller.pwd, 'file:///tmp'); }); @@ -1361,10 +1424,7 @@ void main() { var notifyCount = 0; controller.addListener(() => notifyCount++); - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(notifyCount, greaterThan(0)); }); @@ -1374,10 +1434,7 @@ void main() { controller.onPwdChanged = () {}; controller.addListener(() => notifyCount++); - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(notifyCount, 1); }); @@ -1388,10 +1445,7 @@ void main() { controller.onPwdChanged = null; controller.addListener(() => notifyCount++); - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(notifyCount, 1); }); @@ -1400,10 +1454,7 @@ void main() { var fired = false; controller.onPwdChanged = () => fired = true; - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(fired, isTrue); }); @@ -1412,10 +1463,7 @@ void main() { var pwd = ''; controller.onPwdChanged = () => pwd = controller.pwd; - writeTerminalUtf8( - session(controller).terminal, - '\x1b]7;file:///tmp\x07', - ); + writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); expect(pwd, 'file:///tmp'); }); diff --git a/packages/flterm/test/foundation/surface_geometry_test.dart b/packages/flterm/test/foundation/surface_geometry_test.dart new file mode 100644 index 00000000..52961715 --- /dev/null +++ b/packages/flterm/test/foundation/surface_geometry_test.dart @@ -0,0 +1,132 @@ +import 'package:flterm/src/foundation/surface_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SurfaceGeometry', () { + SurfaceMeasurement measurement({ + int cols = 80, + int rows = 24, + double cellWidth = 8, + double cellHeight = 16, + double paddingLeft = 4, + double paddingRight = 4, + double paddingTop = 2, + double paddingBottom = 2, + double devicePixelRatio = 2, + }) => SurfaceMeasurement( + cols: cols, + rows: rows, + cellWidth: cellWidth, + cellHeight: cellHeight, + paddingLeft: paddingLeft, + paddingRight: paddingRight, + paddingTop: paddingTop, + paddingBottom: paddingBottom, + devicePixelRatio: devicePixelRatio, + ); + + group('tryFrom', () { + test('normalizes logical measurements to physical pixels', () { + final geometry = SurfaceGeometry.tryFrom(measurement()); + + expect( + ( + cellWidth: geometry?.cellWidthPx, + cellHeight: geometry?.cellHeightPx, + screenWidth: geometry?.screenWidth, + screenHeight: geometry?.screenHeight, + ), + (cellWidth: 16, cellHeight: 32, screenWidth: 1296, screenHeight: 776), + ); + }); + + test('rejects an empty grid', () { + final geometry = SurfaceGeometry.tryFrom(measurement(cols: 0)); + + expect(geometry, isNull); + }); + + test('rejects a grid beyond the native limit', () { + final geometry = SurfaceGeometry.tryFrom(measurement(rows: 0x10000)); + + expect(geometry, isNull); + }); + + test('rejects a non-positive cell size', () { + final geometry = SurfaceGeometry.tryFrom(measurement(cellWidth: 0)); + + expect(geometry, isNull); + }); + + test('rejects a non-finite cell size', () { + final geometry = SurfaceGeometry.tryFrom( + measurement(cellHeight: double.nan), + ); + + expect(geometry, isNull); + }); + + test('rejects negative padding', () { + final geometry = SurfaceGeometry.tryFrom(measurement(paddingTop: -1)); + + expect(geometry, isNull); + }); + + test('rejects a non-positive device pixel ratio', () { + final geometry = SurfaceGeometry.tryFrom( + measurement(devicePixelRatio: 0), + ); + + expect(geometry, isNull); + }); + + test('rejects a cell that rounds to zero physical pixels', () { + final geometry = SurfaceGeometry.tryFrom( + measurement(cellWidth: 0.1, devicePixelRatio: 1), + ); + + expect(geometry, isNull); + }); + + test('rejects a physical measurement beyond the mouse limit', () { + final geometry = SurfaceGeometry.tryFrom( + measurement(paddingRight: 0x100000000), + ); + + expect(geometry, isNull); + }); + + test('rejects a screen extent beyond the mouse limit', () { + final geometry = SurfaceGeometry.tryFrom( + measurement( + cols: 0xffff, + cellWidth: 0x10002, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(geometry, isNull); + }); + }); + + group('equality', () { + test('compares normalized measurements by value', () { + final first = SurfaceGeometry.tryFrom(measurement()); + final second = SurfaceGeometry.tryFrom(measurement()); + + expect(first, second); + }); + + test('produces equal hashes for equal measurements', () { + final first = SurfaceGeometry.tryFrom(measurement()); + final second = SurfaceGeometry.tryFrom(measurement()); + + expect(first.hashCode, second.hashCode); + }); + }); + }); +} diff --git a/packages/flterm/test/foundation/terminal_geometry_test.dart b/packages/flterm/test/foundation/terminal_geometry_test.dart deleted file mode 100644 index f7177af9..00000000 --- a/packages/flterm/test/foundation/terminal_geometry_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flterm/src/foundation/terminal_geometry.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('TerminalGeometry', () { - TerminalResizeEvent resizeEvent({ - int cols = 80, - int rows = 24, - double cellWidth = 8, - }) => TerminalResizeEvent( - cols: cols, - rows: rows, - cellWidth: cellWidth, - cellHeight: 16, - paddingLeft: 4, - paddingRight: 4, - paddingTop: 2, - paddingBottom: 2, - devicePixelRatio: 2, - ); - - test('normalizes a valid resize event', () { - final geometry = TerminalGeometry.tryFrom(resizeEvent()); - - expect(geometry, isNotNull); - expect(geometry!.cellWidthPx, 16); - expect(geometry.screenWidth, 1296); - expect(geometry.screenHeight, 776); - }); - - test('compares equivalent normalized measurements as equal', () { - final first = TerminalGeometry.tryFrom(resizeEvent()); - final second = TerminalGeometry.tryFrom(resizeEvent()); - - expect(first, second); - }); - - test('rejects a measurement with an invalid cell width', () { - final geometry = TerminalGeometry.tryFrom(resizeEvent(cellWidth: 0)); - - expect(geometry, isNull); - }); - - test('rejects a measurement beyond the native grid limit', () { - final geometry = TerminalGeometry.tryFrom(resizeEvent(cols: 0x10000)); - - expect(geometry, isNull); - }); - }); -} diff --git a/packages/flterm/test/input/terminal_gesture_detector_test.dart b/packages/flterm/test/input/interaction_region_test.dart similarity index 86% rename from packages/flterm/test/input/terminal_gesture_detector_test.dart rename to packages/flterm/test/input/interaction_region_test.dart index cb57926e..57d9fc69 100644 --- a/packages/flterm/test/input/terminal_gesture_detector_test.dart +++ b/packages/flterm/test/input/interaction_region_test.dart @@ -5,10 +5,10 @@ import 'dart:convert'; import 'package:flterm/src/controller/terminal_controller.dart'; import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/input/terminal_gesture_detector.dart'; +import 'package:flterm/src/input/interaction_region.dart'; import 'package:flterm/src/links/link_interaction.dart'; import 'package:flterm/src/links/link_settings.dart'; -import 'package:flterm/src/view/terminal_view_attachment.dart'; +import 'package:flterm/src/view/view_attachment.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/gestures.dart'; @@ -51,7 +51,7 @@ extension _SelectionEdges on Selection { } void main() { - group('TerminalGestureDetector', () { + group('InteractionRegion', () { const defaultMetrics = CellMetrics( cellWidth: 8, cellHeight: 16, @@ -69,11 +69,11 @@ void main() { utf8.encode('\x1b[?1003h\x1b[?1006h'), ); - final adapters = {}; + final adapters = {}; - TerminalViewAttachment bindingFor(TerminalController controller) { + ViewAttachment bindingFor(TerminalController controller) { return adapters.putIfAbsent(controller, () { - final adapter = TerminalViewAttachment(controller); + final adapter = ViewAttachment(controller); addTearDown(adapter.dispose); return adapter; }); @@ -93,7 +93,7 @@ void main() { int rows = 24, }) { bindingFor(controller).handleResize( - TerminalResizeEvent( + SurfaceMeasurement( cols: cols, rows: rows, cellWidth: defaultMetrics.cellWidth, @@ -109,7 +109,7 @@ void main() { Widget buildHandler({ required TerminalController controller, - TerminalViewAttachment? attachment, + ViewAttachment? attachment, CellMetrics metrics = defaultMetrics, TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), LinkInteraction? links, @@ -118,15 +118,17 @@ void main() { ScrollPhysics scrollPhysics = const ClampingScrollPhysics(), }) { final resolvedAttachment = attachment ?? bindingFor(controller); + final resolvedLinks = links ?? LinkInteraction(); + if (links == null) addTearDown(resolvedLinks.dispose); return Directionality( textDirection: TextDirection.ltr, child: Align( alignment: Alignment.topLeft, - child: TerminalGestureDetector( + child: InteractionRegion( attachment: resolvedAttachment, metrics: metrics, interaction: resolvedAttachment.interaction.value, - links: links ?? LinkInteraction(), + links: resolvedLinks, onLinkActivate: onLinkActivate, settings: gestureSettings, scrollController: scrollController, @@ -139,6 +141,7 @@ void main() { LinkInteraction linkInteractionFor(TerminalController controller) { final links = LinkInteraction(); + addTearDown(links.dispose); links.update( context: LinkContext( terminal: terminalFor(controller), @@ -230,9 +233,15 @@ void main() { late TerminalController controller; - setUp(() => controller = TerminalController()); + setUp(() { + adapters.clear(); + controller = TerminalController(); + }); - tearDown(() => controller.dispose()); + tearDown(() { + controller.dispose(); + adapters.clear(); + }); Future tapMouse( WidgetTester tester, @@ -245,445 +254,457 @@ void main() { } } - testWidgets('tap leaves selection empty', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + group('selection ownership', () { + testWidgets('tap leaves selection empty', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - await tapMouse(tester, const Offset(40, 16)); + await tapMouse(tester, const Offset(40, 16)); - expect(terminalFor(controller).selection, isNull); - }); + expect(terminalFor(controller).selection, isNull); + }); - testWidgets('tap activates a link without starting selection', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); + testWidgets('tap activates a link without starting selection', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); - await tapMouse(tester, const Offset(8, 0)); + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); - expect(links, hasLength(1)); - expect(links.single.text, 'https://example.test'); - expect(terminalFor(controller).selection, isNull); - }); + await tapMouse(tester, const Offset(8, 0)); - testWidgets('tap up activates the press candidate after invalidation', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); + expect(links, hasLength(1)); + expect(links.single.text, 'https://example.test'); + expect(terminalFor(controller).selection, isNull); + }); - final gesture = await mouseDown(tester, const Offset(8, 0)); - linkInteraction.invalidateContent(); - await gesture.up(); + testWidgets('tap up activates the press candidate after invalidation', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); - expect(links.single.text, 'https://example.test'); - }); + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); - testWidgets('replacing link interaction cancels the outgoing press', ( - tester, - ) async { - writeToTerminal(controller, 'https://example.test'); - final outgoing = linkInteractionFor(controller); - final incoming = linkInteractionFor(controller); + final gesture = await mouseDown(tester, const Offset(8, 0)); + linkInteraction.invalidateContent(); + await gesture.up(); - await tester.pumpWidget( - buildHandler(controller: controller, links: outgoing), - ); - final gesture = await mouseDown(tester, const Offset(8, 0)); - await tester.pumpWidget( - buildHandler(controller: controller, links: incoming), - ); + expect(links.single.text, 'https://example.test'); + }); - final staleLink = outgoing.handleRelease( - localPosition: const Offset(8, 0), - metrics: defaultMetrics, - ); - await gesture.up(); + testWidgets('replacing link interaction cancels the outgoing press', ( + tester, + ) async { + writeToTerminal(controller, 'https://example.test'); + final outgoing = linkInteractionFor(controller); + final incoming = linkInteractionFor(controller); - expect(staleLink, isNull); - }); + await tester.pumpWidget( + buildHandler(controller: controller, links: outgoing), + ); + final gesture = await mouseDown(tester, const Offset(8, 0)); + await tester.pumpWidget( + buildHandler(controller: controller, links: incoming), + ); - testWidgets('drag cancels claimed link tap', (tester) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); + final staleLink = outgoing.handleRelease( + localPosition: const Offset(8, 0), + metrics: defaultMetrics, + ); + await gesture.up(); - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); + expect(staleLink, isNull); + }); - final gesture = await mouseDown(tester, const Offset(8, 0)); - await tester.pump(kPressTimeout); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); + testWidgets('drag cancels claimed link tap', (tester) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); - expect(links, isEmpty); - }); + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); - testWidgets('touch scroll cancels a claimed link tap', (tester) async { - writeToTerminal(controller, '\x1b[?1049hhttps://example.test'); - final linkInteraction = linkInteractionFor(controller); + final gesture = await mouseDown(tester, const Offset(8, 0)); + await tester.pump(kPressTimeout); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); - await tester.pumpWidget( - buildHandler(controller: controller, links: linkInteraction), - ); - final gesture = await tester.startGesture(const Offset(8, 8)); - await tester.pump(kPressTimeout); - await gesture.moveBy(const Offset(0, 64)); - await gesture.up(); - final released = linkInteraction.handleRelease( - localPosition: const Offset(8, 8), - metrics: defaultMetrics, - ); + expect(links, isEmpty); + }); - expect(released, isNull); - }); + testWidgets('touch scroll cancels a claimed link tap', (tester) async { + writeToTerminal(controller, '\x1b[?1049hhttps://example.test'); + final linkInteraction = linkInteractionFor(controller); - testWidgets('mouse tracking takes priority over link activation', ( - tester, - ) async { - final links = []; - writeToTerminal(controller, 'https://example.test'); - final linkInteraction = linkInteractionFor(controller); - enableMouseTracking(controller); - - await tester.pumpWidget( - buildHandler( - controller: controller, - links: linkInteraction, - onLinkActivate: links.add, - ), - ); + await tester.pumpWidget( + buildHandler(controller: controller, links: linkInteraction), + ); + final gesture = await tester.startGesture(const Offset(8, 8)); + await tester.pump(kPressTimeout); + await gesture.moveBy(const Offset(0, 64)); + await gesture.up(); + final released = linkInteraction.handleRelease( + localPosition: const Offset(8, 8), + metrics: defaultMetrics, + ); - await tapMouse(tester, const Offset(8, 0)); + expect(released, isNull); + }); - expect(links, isEmpty); - }); + testWidgets('mouse tracking takes priority over link activation', ( + tester, + ) async { + final links = []; + writeToTerminal(controller, 'https://example.test'); + final linkInteraction = linkInteractionFor(controller); + enableMouseTracking(controller); - testWidgets('drag creates selection with correct cells', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + await tester.pumpWidget( + buildHandler( + controller: controller, + links: linkInteraction, + onLinkActivate: links.add, + ), + ); - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(40, 16)); - await gesture.up(); + await tapMouse(tester, const Offset(8, 0)); - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 1); - expect(selection.endRow, 1); - expect(selection.endCol, 5); - expect(selection.mode, TerminalSelectionShape.normal); - }); + expect(links, isEmpty); + }); - testWidgets('stylus drag creates selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('drag creates selection with correct cells', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await tester.startGesture( - const Offset(8, 0), - kind: .stylus, - pointer: 83, - ); - await gesture.moveTo(const Offset(40, 16)); - await gesture.up(); + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); - expect(terminalFor(controller).selection, isNotNull); - }); + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 1); + expect(selection.endRow, 1); + expect(selection.endCol, 5); + expect(selection.mode, TerminalSelectionShape.normal); + }); - testWidgets('inverted stylus drag creates selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('stylus drag creates selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await tester.startGesture( - const Offset(8, 0), - kind: .invertedStylus, - pointer: 84, - ); - await gesture.moveTo(const Offset(40, 16)); - await gesture.up(); + final gesture = await tester.startGesture( + const Offset(8, 0), + kind: .stylus, + pointer: 83, + ); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); - expect(terminalFor(controller).selection, isNotNull); - }); + expect(terminalFor(controller).selection, isNotNull); + }); - testWidgets('mouse up ends selection drag', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('inverted stylus drag creates selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); + final gesture = await tester.startGesture( + const Offset(8, 0), + kind: .invertedStylus, + pointer: 84, + ); + await gesture.moveTo(const Offset(40, 16)); + await gesture.up(); - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.endRow, 2); - }); + expect(terminalFor(controller).selection, isNotNull); + }); - testWidgets('drag to same cell does not change selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('mouse up ends selection drag', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await mouseDown(tester, const Offset(8, 0)); - await gesture.moveTo(const Offset(40, 16)); - final selAfterFirst = terminalFor(controller).selection; + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); - await gesture.moveTo(const Offset(41, 17)); - final selAfterSecond = terminalFor(controller).selection; + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.endRow, 2); + }); - expect(selAfterFirst, selAfterSecond); + testWidgets('drag to same cell does not change selection', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); - await gesture.up(); - }); + final gesture = await mouseDown(tester, const Offset(8, 0)); + await gesture.moveTo(const Offset(40, 16)); + final selAfterFirst = terminalFor(controller).selection; - testWidgets('selection autoscroll follows the committed grid', ( - tester, - ) async { - final target = TerminalController( - config: const TerminalConfig(cols: 10, rows: 2), - ); - addTearDown(target.dispose); - final attachment = bindingFor(target); - final scrollController = ScrollController(); - addTearDown(scrollController.dispose); - commitGeometry(target, cols: 10, rows: 2); - writeToTerminal(target, '0\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9'); - target.scrollToTop(); - await tester.pumpWidget( - Directionality( - textDirection: .ltr, - child: Scrollable( - controller: scrollController, - viewportBuilder: (_, _) => buildHandler( - controller: target, - attachment: attachment, - scrollController: scrollController, + await gesture.moveTo(const Offset(41, 17)); + final selAfterSecond = terminalFor(controller).selection; + + expect(selAfterFirst, selAfterSecond); + + await gesture.up(); + }); + + testWidgets('selection autoscroll follows the committed grid', ( + tester, + ) async { + final target = TerminalController( + config: const TerminalConfig(cols: 10, rows: 2), + ); + addTearDown(target.dispose); + final attachment = bindingFor(target); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + commitGeometry(target, cols: 10, rows: 2); + writeToTerminal( + target, + '0\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9', + ); + target.scrollToTop(); + await tester.pumpWidget( + Directionality( + textDirection: .ltr, + child: Scrollable( + controller: scrollController, + viewportBuilder: (_, _) => buildHandler( + controller: target, + attachment: attachment, + scrollController: scrollController, + ), ), ), - ), - ); - final pointer = await mouseDown(tester, const Offset(8, 8)); - - await pointer.moveTo(const Offset(8, 64)); - await tester.pump(); - final rowAfterMove = attachment.terminal.scrollbar.offset; - await tester.pump(const Duration(milliseconds: 120)); - final viewportRow = attachment.terminal.scrollbar.offset; - await pointer.up(); - await tester.pump(const Duration(milliseconds: 250)); + ); + final pointer = await mouseDown(tester, const Offset(8, 8)); - expect(viewportRow, greaterThan(rowAfterMove)); - }); + await pointer.moveTo(const Offset(8, 64)); + await tester.pump(); + final rowAfterMove = attachment.terminal.scrollbar.offset; + await tester.pump(const Duration(milliseconds: 120)); + final viewportRow = attachment.terminal.scrollbar.offset; + await pointer.up(); + await tester.pump(const Duration(milliseconds: 250)); - testWidgets('double click selects word', (tester) async { - writeToTerminal(controller, 'hello world'); + expect(viewportRow, greaterThan(rowAfterMove)); + }); - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('double click selects word', (tester) async { + writeToTerminal(controller, 'hello world'); - await tapMouse(tester, const Offset(8, 0), count: 2); + await tester.pumpWidget(buildHandler(controller: controller)); - final selection = terminalFor(controller).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 0); - expect(selection.endCol, 5); - }); + await tapMouse(tester, const Offset(8, 0), count: 2); - testWidgets('distant pointer timestamps do not form a double click', ( - tester, - ) async { - writeToTerminal(controller, 'hello world'); - await tester.pumpWidget(buildHandler(controller: controller)); + final selection = terminalFor(controller).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 0); + expect(selection.endCol, 5); + }); - const position = Offset(8, 0); - final firstPointer = TestPointer(81, PointerDeviceKind.mouse); - await sendPointerEvent(tester, firstPointer.down(position)); - await sendPointerEvent( - tester, - firstPointer.up(timeStamp: const Duration(milliseconds: 10)), - ); - final secondPointer = TestPointer(82, PointerDeviceKind.mouse); - await sendPointerEvent( + testWidgets('distant pointer timestamps do not form a double click', ( tester, - secondPointer.down(position, timeStamp: const Duration(seconds: 1)), - ); - await sendPointerEvent( - tester, - secondPointer.up(timeStamp: const Duration(milliseconds: 1010)), - ); + ) async { + writeToTerminal(controller, 'hello world'); + await tester.pumpWidget(buildHandler(controller: controller)); - expect(terminalFor(controller).selection, isNull); - }); + const position = Offset(8, 0); + final firstPointer = TestPointer(81, PointerDeviceKind.mouse); + await sendPointerEvent(tester, firstPointer.down(position)); + await sendPointerEvent( + tester, + firstPointer.up(timeStamp: const Duration(milliseconds: 10)), + ); + final secondPointer = TestPointer(82, PointerDeviceKind.mouse); + await sendPointerEvent( + tester, + secondPointer.down(position, timeStamp: const Duration(seconds: 1)), + ); + await sendPointerEvent( + tester, + secondPointer.up(timeStamp: const Duration(milliseconds: 1010)), + ); - testWidgets('double click on second word selects it', (tester) async { - writeToTerminal(controller, 'hello world'); + expect(terminalFor(controller).selection, isNull); + }); - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('double click on second word selects it', (tester) async { + writeToTerminal(controller, 'hello world'); - await tapMouse(tester, const Offset(56, 0), count: 2); + await tester.pumpWidget(buildHandler(controller: controller)); - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 6); - expect(selection.endCol, 11); - }); + await tapMouse(tester, const Offset(56, 0), count: 2); - testWidgets('double click uses configured word boundaries', (tester) async { - final boundaryController = TerminalController(); - addTearDown(boundaryController.dispose); - writeToTerminal(boundaryController, 'hello_world'); + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 6); + expect(selection.endCol, 11); + }); - await tester.pumpWidget( - buildHandler( - controller: boundaryController, - gestureSettings: const TerminalGestureSettings(wordBoundaries: '_'), - ), - ); + testWidgets('double click uses configured word boundaries', ( + tester, + ) async { + final boundaryController = TerminalController(); + addTearDown(boundaryController.dispose); + writeToTerminal(boundaryController, 'hello_world'); - await tapMouse(tester, const Offset(64, 0), count: 2); + await tester.pumpWidget( + buildHandler( + controller: boundaryController, + gestureSettings: const TerminalGestureSettings(wordBoundaries: '_'), + ), + ); - final selection = terminalFor(boundaryController).selection!; - expect(selection.startCol, 6); - expect(selection.endCol, 11); - }); + await tapMouse(tester, const Offset(64, 0), count: 2); - testWidgets('triple click selects line content only', (tester) async { - writeToTerminal(controller, 'Hello'); + final selection = terminalFor(boundaryController).selection!; + expect(selection.startCol, 6); + expect(selection.endCol, 11); + }); - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('triple click selects line content only', (tester) async { + writeToTerminal(controller, 'Hello'); - await tapMouse(tester, const Offset(40, 0), count: 3); + await tester.pumpWidget(buildHandler(controller: controller)); - final selection = terminalFor(controller).selection!; - expect(selection.startCol, 0); - expect(selection.endCol, 5); - }); + await tapMouse(tester, const Offset(40, 0), count: 3); - testWidgets('triple click on wrapped line selects full terminal line', ( - tester, - ) async { - final narrowController = TerminalController( - config: const TerminalConfig(cols: 10, rows: 5), - ); - addTearDown(narrowController.dispose); + final selection = terminalFor(controller).selection!; + expect(selection.startCol, 0); + expect(selection.endCol, 5); + }); - writeToTerminal(narrowController, 'ABCDEFGHIJKLMNO'); + testWidgets('triple click on wrapped line selects full terminal line', ( + tester, + ) async { + final narrowController = TerminalController( + config: const TerminalConfig(cols: 10, rows: 5), + ); + addTearDown(narrowController.dispose); - await tester.pumpWidget(buildHandler(controller: narrowController)); + writeToTerminal(narrowController, 'ABCDEFGHIJKLMNO'); - await tapMouse(tester, const Offset(8, 16), count: 3); + await tester.pumpWidget(buildHandler(controller: narrowController)); - final selection = terminalFor(narrowController).selection!; - expect(selection.startRow, 0); - expect(selection.startCol, 0); - expect(selection.endRow, 1); - expect(selection.endCol, 5); - }); + await tapMouse(tester, const Offset(8, 16), count: 3); - testWidgets('triple click with fullRow mode selects entire row width', ( - tester, - ) async { - final wideController = TerminalController( - config: const TerminalConfig(cols: 20, rows: 5), - ); - addTearDown(wideController.dispose); + final selection = terminalFor(narrowController).selection!; + expect(selection.startRow, 0); + expect(selection.startCol, 0); + expect(selection.endRow, 1); + expect(selection.endCol, 5); + }); - writeToTerminal(wideController, 'Hello'); + testWidgets('triple click with fullRow mode selects entire row width', ( + tester, + ) async { + final wideController = TerminalController( + config: const TerminalConfig(cols: 20, rows: 5), + ); + addTearDown(wideController.dispose); - await tester.pumpWidget( - buildHandler( - controller: wideController, - gestureSettings: const TerminalGestureSettings(lineSelectMode: .full), - ), - ); + writeToTerminal(wideController, 'Hello'); - await tapMouse(tester, const Offset(8, 0), count: 3); + await tester.pumpWidget( + buildHandler( + controller: wideController, + gestureSettings: const TerminalGestureSettings( + lineSelectMode: .full, + ), + ), + ); - final selection = terminalFor(wideController).selection!; - expect(selection.endCol, 20); - }); + await tapMouse(tester, const Offset(8, 0), count: 3); - testWidgets('tap counting resets on distant clicks', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + final selection = terminalFor(wideController).selection!; + expect(selection.endCol, 20); + }); - await tapMouse(tester, const Offset(40, 16)); - await tapMouse(tester, const Offset(200, 200)); + testWidgets('tap counting resets on distant clicks', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - expect(terminalFor(controller).selection, isNull); - }); + await tapMouse(tester, const Offset(40, 16)); + await tapMouse(tester, const Offset(200, 200)); - testWidgets('touch long press starts normal selection by default', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); + expect(terminalFor(controller).selection, isNull); + }); - final gesture = await tester.startGesture(const Offset(40, 16)); + testWidgets('touch long press starts normal selection by default', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); - await tester.pump(const Duration(milliseconds: 550)); + final gesture = await tester.startGesture(const Offset(40, 16)); - expect(terminalFor(controller).selection, isNull); + await tester.pump(const Duration(milliseconds: 550)); - await gesture.moveTo(const Offset(80, 32)); - final sel = terminalFor(controller).selection!; - expect(sel.mode, TerminalSelectionShape.normal); + expect(terminalFor(controller).selection, isNull); - await gesture.up(); - }); + await gesture.moveTo(const Offset(80, 32)); + final sel = terminalFor(controller).selection!; + expect(sel.mode, TerminalSelectionShape.normal); - testWidgets('touch move cancels long press if distance exceeds threshold', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); + await gesture.up(); + }); + + testWidgets( + 'touch move cancels long press if distance exceeds threshold', + (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await tester.startGesture(const Offset(40, 16)); - await gesture.moveTo(const Offset(80, 16)); + final gesture = await tester.startGesture(const Offset(40, 16)); + await gesture.moveTo(const Offset(80, 16)); - await tester.pump(const Duration(milliseconds: 550)); + await tester.pump(const Duration(milliseconds: 550)); - await gesture.moveTo(const Offset(120, 16)); - expect(terminalFor(controller).selection, isNull); + await gesture.moveTo(const Offset(120, 16)); + expect(terminalFor(controller).selection, isNull); - await gesture.up(); - }); + await gesture.up(); + }, + ); - testWidgets('new click clears existing selection', (tester) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('new click clears existing selection', (tester) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await mouseDown(tester, Offset.zero); - await gesture.moveTo(const Offset(80, 32)); - await gesture.up(); + final gesture = await mouseDown(tester, Offset.zero); + await gesture.moveTo(const Offset(80, 32)); + await gesture.up(); - expect(terminalFor(controller).selection, isNotNull); + expect(terminalFor(controller).selection, isNotNull); - final gesture2 = await mouseDown(tester, const Offset(40, 16)); - await gesture2.up(); + final gesture2 = await mouseDown(tester, const Offset(40, 16)); + await gesture2.up(); - expect(terminalFor(controller).selection, isNull); - }); + expect(terminalFor(controller).selection, isNull); + }); - testWidgets('click without existing selection keeps selection null', ( - tester, - ) async { - await tester.pumpWidget(buildHandler(controller: controller)); + testWidgets('click without existing selection keeps selection null', ( + tester, + ) async { + await tester.pumpWidget(buildHandler(controller: controller)); - final gesture = await mouseDown(tester, const Offset(40, 16)); - await gesture.up(); + final gesture = await mouseDown(tester, const Offset(40, 16)); + await gesture.up(); - expect(terminalFor(controller).selection, isNull); + expect(terminalFor(controller).selection, isNull); + }); }); group('gesture settings', () { @@ -1176,7 +1197,7 @@ void main() { addTearDown(replacement.dispose); writeToTerminal(replacement, 'selected'); bindingFor(replacement).handleResize( - TerminalResizeEvent( + SurfaceMeasurement( cols: 80, rows: 24, cellWidth: defaultMetrics.cellWidth, @@ -2979,7 +3000,7 @@ void main() { expect(events, isNotEmpty); }); - testWidgets('disposes active inertia when detector unmounts', ( + testWidgets('disposes active inertia when region unmounts', ( tester, ) async { enableSgrMouseTracking(controller); diff --git a/packages/flterm/test/input/terminal_input_client_test.dart b/packages/flterm/test/input/text_input_session_test.dart similarity index 99% rename from packages/flterm/test/input/terminal_input_client_test.dart rename to packages/flterm/test/input/text_input_session_test.dart index 1a01f397..b6ddfe6a 100644 --- a/packages/flterm/test/input/terminal_input_client_test.dart +++ b/packages/flterm/test/input/text_input_session_test.dart @@ -1,12 +1,12 @@ -import 'package:flterm/src/input/terminal_input_client.dart'; +import 'package:flterm/src/input/text_input_session.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('TerminalInputClient', () { - late TerminalInputClient handler; + group('TextInputSession', () { + late TextInputSession handler; late List commits; late List deletes; late List newlines; @@ -52,7 +52,7 @@ void main() { } setUp(() { - handler = TerminalInputClient()..viewId = 0; + handler = TextInputSession()..viewId = 0; commits = []; deletes = []; newlines = []; @@ -978,7 +978,7 @@ void main() { group('ensureAttached', () { test('throws when no Flutter view is set', () { - final client = TerminalInputClient(); + final client = TextInputSession(); addTearDown(client.detach); expect( @@ -1014,7 +1014,7 @@ void main() { test('reopens a connection orphaned by another client', () { final calls = recordTextInputCalls(); handler.ensureAttached(); - final other = TerminalInputClient()..viewId = 0; + final other = TextInputSession()..viewId = 0; addTearDown(other.detach); other.ensureAttached(); calls.clear(); @@ -1035,7 +1035,7 @@ void main() { composing: TextRange(start: 1, end: 3), ), ); - final other = TerminalInputClient()..viewId = 0; + final other = TextInputSession()..viewId = 0; addTearDown(other.detach); other.ensureAttached(); preedit.clear(); diff --git a/packages/flterm/test/links/terminal_logical_line_test.dart b/packages/flterm/test/links/logical_line_test.dart similarity index 89% rename from packages/flterm/test/links/terminal_logical_line_test.dart rename to packages/flterm/test/links/logical_line_test.dart index 38917c3e..0135c9c8 100644 --- a/packages/flterm/test/links/terminal_logical_line_test.dart +++ b/packages/flterm/test/links/logical_line_test.dart @@ -1,12 +1,12 @@ import 'package:flterm/src/foundation.dart' show CellRange; -import 'package:flterm/src/links/terminal_logical_line.dart'; +import 'package:flterm/src/links/logical_line.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show Position; void main() { - group('TerminalLogicalLine', () { - TerminalLogicalLine line(String text, List cells) { - return TerminalLogicalLine( + group('LogicalLine', () { + LogicalLine line(String text, List cells) { + return LogicalLine( text, cells, cells, diff --git a/packages/flterm/test/links/osc8_link_detector_test.dart b/packages/flterm/test/links/osc8_link_detector_test.dart index 5901ec91..f01e90d5 100644 --- a/packages/flterm/test/links/osc8_link_detector_test.dart +++ b/packages/flterm/test/links/osc8_link_detector_test.dart @@ -1,15 +1,15 @@ +import 'package:flterm/src/links/logical_line.dart'; import 'package:flterm/src/links/osc8_link_detector.dart'; -import 'package:flterm/src/links/terminal_logical_line.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show Position; void main() { group('Osc8LinkDetector', () { - TerminalLogicalLine line(String text, List uris) { + LogicalLine line(String text, List uris) { final cells = [ for (var i = 0; i < text.length; i++) Position(row: 0, col: i), ]; - return TerminalLogicalLine(text, cells, cells, uris); + return LogicalLine(text, cells, cells, uris); } group('matches', () { diff --git a/packages/flterm/test/links/text_link_detector_test.dart b/packages/flterm/test/links/text_link_detector_test.dart index bb728bb3..9198b8fe 100644 --- a/packages/flterm/test/links/text_link_detector_test.dart +++ b/packages/flterm/test/links/text_link_detector_test.dart @@ -1,6 +1,5 @@ import 'package:flterm/src/links/link_settings.dart' show LinkRule, LinkType; -import 'package:flterm/src/links/terminal_logical_line.dart' - show TerminalLogicalLine; +import 'package:flterm/src/links/logical_line.dart' show LogicalLine; import 'package:flterm/src/links/text_link_detector.dart' show TextLinkDetector; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show Position; @@ -13,11 +12,11 @@ void main() { detector = TextLinkDetector(); }); - TerminalLogicalLine line(String text) { + LogicalLine line(String text) { final cells = [ for (var i = 0; i < text.length; i++) Position(row: 0, col: i), ]; - return TerminalLogicalLine( + return LogicalLine( text, cells, cells, diff --git a/packages/flterm/test/rendering/terminal_render_cache_test.dart b/packages/flterm/test/rendering/atlas_pool_test.dart similarity index 60% rename from packages/flterm/test/rendering/terminal_render_cache_test.dart rename to packages/flterm/test/rendering/atlas_pool_test.dart index 79e6c1b4..f6ced40c 100644 --- a/packages/flterm/test/rendering/terminal_render_cache_test.dart +++ b/packages/flterm/test/rendering/atlas_pool_test.dart @@ -2,11 +2,11 @@ import 'dart:ui'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering/atlas/atlas_config.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('TerminalRenderCache', () { + group('AtlasPool', () { AtlasConfig key({double fontSize = 14}) { return AtlasConfig( fontSize: fontSize, @@ -19,23 +19,23 @@ void main() { } test('shares atlas for matching keys', () { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); + final pool = AtlasPool(); + addTearDown(pool.dispose); - final first = cache.acquireAtlas(key()); - final second = cache.acquireAtlas(key()); + final first = pool.acquireAtlas(key()); + final second = pool.acquireAtlas(key()); addTearDown(second.release); addTearDown(first.release); expect(second.atlas, same(first.atlas)); }); - test('keeps atlas alive until the last handle is released', () { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); + test('keeps atlas alive until the last lease is released', () { + final pool = AtlasPool(); + addTearDown(pool.dispose); - final first = cache.acquireAtlas(key()); - final second = cache.acquireAtlas(key()); + final first = pool.acquireAtlas(key()); + final second = pool.acquireAtlas(key()); final atlas = first.atlas; first.release(); @@ -46,11 +46,11 @@ void main() { }); test('does not share atlas across font-affecting keys', () { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); + final pool = AtlasPool(); + addTearDown(pool.dispose); - final first = cache.acquireAtlas(key()); - final second = cache.acquireAtlas(key(fontSize: 16)); + final first = pool.acquireAtlas(key()); + final second = pool.acquireAtlas(key(fontSize: 16)); addTearDown(second.release); addTearDown(first.release); diff --git a/packages/flterm/test/rendering/cursor_layer_test.dart b/packages/flterm/test/rendering/cursor_layer_test.dart index cfb890cd..3f90f9eb 100644 --- a/packages/flterm/test/rendering/cursor_layer_test.dart +++ b/packages/flterm/test/rendering/cursor_layer_test.dart @@ -6,7 +6,7 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,10 +21,10 @@ void main() { const emojiFallback = ['Noto Emoji', 'JetBrains Mono']; const rows = 3; - TerminalRenderCache renderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool atlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } TerminalTheme cursorTheme( @@ -60,7 +60,7 @@ void main() { applyTerminalTheme(terminal, theme); final width = cols * metrics.cellWidth; final height = rows * metrics.cellHeight; - final frameSource = TerminalFrameSource(terminal); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = Size(width, height); @@ -81,7 +81,7 @@ void main() { metrics: metrics, frameSource: frameSource, offset: ViewportOffset.zero(), - renderCache: renderCache(), + atlasPool: atlasPool(), focused: true, onGeometryChanged: (_) {}, onViewportRowChanged: (_) {}, diff --git a/packages/flterm/test/rendering/emoji_golden_test.dart b/packages/flterm/test/rendering/emoji_golden_test.dart index 23b07ea2..f377209a 100644 --- a/packages/flterm/test/rendering/emoji_golden_test.dart +++ b/packages/flterm/test/rendering/emoji_golden_test.dart @@ -8,7 +8,7 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -59,10 +59,10 @@ void main() { ], ); - TerminalRenderCache renderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool atlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } void writeRawBytes(Terminal terminal, List bytes) { @@ -97,7 +97,7 @@ void main() { bool focused = true, }) async { selection?.applyTo(terminal); - final frameSource = TerminalFrameSource(terminal); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); final resolvedTheme = theme ?? emojiTheme; applyTerminalTheme(terminal, resolvedTheme); @@ -122,7 +122,7 @@ void main() { theme: resolvedTheme, metrics: metrics, offset: ViewportOffset.zero(), - renderCache: renderCache(), + atlasPool: atlasPool(), focused: focused, onGeometryChanged: (_) {}, onViewportRowChanged: (_) {}, diff --git a/packages/flterm/test/rendering/terminal_frame_builder_test.dart b/packages/flterm/test/rendering/frame_builder_test.dart similarity index 95% rename from packages/flterm/test/rendering/terminal_frame_builder_test.dart rename to packages/flterm/test/rendering/frame_builder_test.dart index a833a19a..258ca126 100644 --- a/packages/flterm/test/rendering/terminal_frame_builder_test.dart +++ b/packages/flterm/test/rendering/frame_builder_test.dart @@ -10,13 +10,13 @@ import 'package:flterm/src/foundation/dynamic_color.dart'; import 'package:flterm/src/foundation/terminal_theme.dart'; import 'package:flterm/src/rendering/atlas/atlas.dart'; import 'package:flterm/src/rendering/atlas/sprite_buffer.dart'; +import 'package:flterm/src/rendering/frame_builder.dart'; import 'package:flterm/src/rendering/paint_state.dart'; -import 'package:flterm/src/rendering/terminal_frame_builder.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart'; void main() { - group('TerminalFrameBuilder', () { + group('FrameBuilder', () { const metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); AtlasConfig config() { @@ -84,17 +84,17 @@ void main() { Terminal terminal, Atlas atlas, SpriteBuffer sprites, - TerminalPaintState state, - TerminalFrameBuilder builder, + PaintState state, + FrameBuilder builder, }) createFrame({required int cols, required int rows}) { final terminal = Terminal(cols: cols, rows: rows); final atlas = Atlas(config()); final sprites = SpriteBuffer(); - final state = TerminalPaintState(TerminalTheme.dark(), metrics) + final state = PaintState(TerminalTheme.dark(), metrics) ..cols = cols ..rows = rows; - final builder = TerminalFrameBuilder(atlas, sprites, state) + final builder = FrameBuilder(atlas, sprites, state) ..configure(rows, cols); addTearDown(() { builder.dispose(); @@ -114,17 +114,17 @@ void main() { late Terminal terminal; late Atlas atlas; late SpriteBuffer sprites; - late TerminalPaintState state; - late TerminalFrameBuilder builder; + late PaintState state; + late FrameBuilder builder; setUp(() { terminal = Terminal(cols: 8, rows: 2); atlas = Atlas(config()); sprites = SpriteBuffer(); - state = TerminalPaintState(TerminalTheme.dark(), metrics) + state = PaintState(TerminalTheme.dark(), metrics) ..cols = 8 ..rows = 2; - builder = TerminalFrameBuilder(atlas, sprites, state)..configure(2, 8); + builder = FrameBuilder(atlas, sprites, state)..configure(2, 8); }); tearDown(() { @@ -160,14 +160,11 @@ void main() { final localTerminal = Terminal(cols: 260, rows: 1); final localAtlas = Atlas(config()); final localSprites = SpriteBuffer(); - final localState = TerminalPaintState(TerminalTheme.dark(), metrics) + final localState = PaintState(TerminalTheme.dark(), metrics) ..cols = 260 ..rows = 1; - final localBuilder = TerminalFrameBuilder( - localAtlas, - localSprites, - localState, - )..configure(1, 260); + final localBuilder = FrameBuilder(localAtlas, localSprites, localState) + ..configure(1, 260); addTearDown(() { localBuilder.dispose(); localSprites.dispose(); diff --git a/packages/flterm/test/rendering/frame_source_test.dart b/packages/flterm/test/rendering/frame_source_test.dart new file mode 100644 index 00000000..405da7f7 --- /dev/null +++ b/packages/flterm/test/rendering/frame_source_test.dart @@ -0,0 +1,61 @@ +@Tags(['ffi']) +library; + +import 'package:flterm/src/rendering/frame_source.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' show Terminal; + +void main() { + group('FrameSource', () { + group('notifications', () { + test('publishes terminal changes', () { + final terminal = Terminal(cols: 10, rows: 3); + final viewportChanges = ValueNotifier(0); + final source = FrameSource(terminal, viewportChanges: viewportChanges); + addTearDown(terminal.dispose); + addTearDown(viewportChanges.dispose); + addTearDown(source.dispose); + var notifications = 0; + source.addListener(() => notifications++); + + terminal.write(Uint8List.fromList('hello'.codeUnits)); + + expect(notifications, 1); + }); + + test('publishes viewport changes', () { + final terminal = Terminal(cols: 10, rows: 3); + final viewportChanges = ValueNotifier(0); + final source = FrameSource(terminal, viewportChanges: viewportChanges); + addTearDown(terminal.dispose); + addTearDown(viewportChanges.dispose); + addTearDown(source.dispose); + var notifications = 0; + source.addListener(() => notifications++); + + viewportChanges.value++; + + expect(notifications, 1); + }); + }); + + group('dispose', () { + test('stops publishing source changes', () { + final terminal = Terminal(cols: 10, rows: 3); + final viewportChanges = ValueNotifier(0); + final source = FrameSource(terminal, viewportChanges: viewportChanges); + addTearDown(terminal.dispose); + addTearDown(viewportChanges.dispose); + var notifications = 0; + source.addListener(() => notifications++); + + source.dispose(); + terminal.write(Uint8List.fromList('hello'.codeUnits)); + viewportChanges.value++; + + expect(notifications, 0); + }); + }); + }); +} diff --git a/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart b/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart index 3b49ee52..78ef182b 100644 --- a/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart +++ b/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart @@ -86,8 +86,8 @@ void main() { return out; } - TerminalPaintState stateFor({required int cols, required int rows}) { - return TerminalPaintState( + PaintState stateFor({required int cols, required int rows}) { + return PaintState( TerminalTheme.dark(), const CellMetrics(cellWidth: 1, cellHeight: 1, baseline: 1), ) diff --git a/packages/flterm/test/rendering/kitty_placement_cache_test.dart b/packages/flterm/test/rendering/kitty_placement_cache_test.dart index 422c5045..05ea190a 100644 --- a/packages/flterm/test/rendering/kitty_placement_cache_test.dart +++ b/packages/flterm/test/rendering/kitty_placement_cache_test.dart @@ -26,13 +26,13 @@ void main() { } late Terminal terminal; - late TerminalPaintState state; + late PaintState state; late KittyImageCache images; late KittyPlacementCache placements; setUp(() { terminal = Terminal(cols: 8, rows: 2)..kittyImageStorageLimit = 1 << 20; - state = TerminalPaintState(TerminalTheme.dark(), metrics) + state = PaintState(TerminalTheme.dark(), metrics) ..cols = 8 ..rows = 2; images = KittyImageCache(onImageReady: () {}); diff --git a/packages/flterm/test/rendering/paint_state_test.dart b/packages/flterm/test/rendering/paint_state_test.dart index da63c6a2..774cd447 100644 --- a/packages/flterm/test/rendering/paint_state_test.dart +++ b/packages/flterm/test/rendering/paint_state_test.dart @@ -3,13 +3,13 @@ import 'package:flterm/src/rendering/paint_state.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('TerminalPaintState', () { + group('PaintState', () { final theme = TerminalTheme.dark(); const metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); group('constructor', () { test('computes derived fields from theme', () { - final state = TerminalPaintState(theme, metrics); + final state = PaintState(theme, metrics); expect(state.theme, theme); expect(state.metrics, metrics); @@ -21,7 +21,7 @@ void main() { group('updateTheme', () { test('recomputes derived fields', () { - final state = TerminalPaintState(theme, metrics); + final state = PaintState(theme, metrics); final light = TerminalTheme.light(); state.updateTheme(light); @@ -31,7 +31,7 @@ void main() { }); test('faintAlpha reflects new faintOpacity', () { - final state = TerminalPaintState(theme, metrics); + final state = PaintState(theme, metrics); state.updateTheme(theme.copyWith(faintOpacity: 0.0)); expect(state.faintAlpha, 0); @@ -43,7 +43,7 @@ void main() { group('mutable state', () { test('starts with default frame state', () { - final state = TerminalPaintState(theme, metrics); + final state = PaintState(theme, metrics); expect(state.rows, 0); expect(state.cols, 0); diff --git a/packages/flterm/test/rendering/painters/cursor_painter_test.dart b/packages/flterm/test/rendering/painters/cursor_painter_test.dart index 83283d2c..3967b77b 100644 --- a/packages/flterm/test/rendering/painters/cursor_painter_test.dart +++ b/packages/flterm/test/rendering/painters/cursor_painter_test.dart @@ -30,7 +30,7 @@ void main() { Future render({bool preeditActive = false}) async { final atlas = Atlas(config()); addTearDown(atlas.dispose); - final state = TerminalPaintState(TerminalTheme.dark(), metrics) + final state = PaintState(TerminalTheme.dark(), metrics) ..cols = 2 ..rows = 1 ..cursor = const Cursor() diff --git a/packages/flterm/test/rendering/terminal_render_pipeline_test.dart b/packages/flterm/test/rendering/render_pipeline_test.dart similarity index 87% rename from packages/flterm/test/rendering/terminal_render_pipeline_test.dart rename to packages/flterm/test/rendering/render_pipeline_test.dart index ebb4ca64..40c949bd 100644 --- a/packages/flterm/test/rendering/terminal_render_pipeline_test.dart +++ b/packages/flterm/test/rendering/render_pipeline_test.dart @@ -11,12 +11,12 @@ import 'package:flterm/src/foundation/terminal_theme.dart'; import 'package:flterm/src/links/link_snapshot.dart'; import 'package:flterm/src/rendering/atlas/atlas.dart'; import 'package:flterm/src/rendering/paint_state.dart'; -import 'package:flterm/src/rendering/terminal_render_pipeline.dart'; +import 'package:flterm/src/rendering/render_pipeline.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart'; void main() { - group('TerminalRenderPipeline', () { + group('RenderPipeline', () { const metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); AtlasConfig config({double fontSize = 14}) { @@ -30,7 +30,7 @@ void main() { ); } - void paint(TerminalRenderPipeline pipeline) { + void paint(RenderPipeline pipeline) { final recorder = PictureRecorder(); final canvas = Canvas(recorder); pipeline.paint(canvas); @@ -43,20 +43,17 @@ void main() { late Terminal terminal; late Atlas atlas; - late TerminalPaintState state; - late TerminalRenderPipeline pipeline; + late PaintState state; + late RenderPipeline pipeline; setUp(() { terminal = Terminal(cols: 8, rows: 2); atlas = Atlas(config()); - state = TerminalPaintState(TerminalTheme.dark(), metrics) + state = PaintState(TerminalTheme.dark(), metrics) ..cols = 8 ..rows = 2; - pipeline = TerminalRenderPipeline( - atlas: atlas, - state: state, - onImageReady: () {}, - )..configureGrid(2, 8); + pipeline = RenderPipeline(atlas: atlas, state: state, onImageReady: () {}) + ..configureGrid(2, 8); }); tearDown(() { diff --git a/packages/flterm/test/rendering/row_dirty_tracker_test.dart b/packages/flterm/test/rendering/row_dirty_tracker_test.dart index a43dae54..ebc51fd0 100644 --- a/packages/flterm/test/rendering/row_dirty_tracker_test.dart +++ b/packages/flterm/test/rendering/row_dirty_tracker_test.dart @@ -1,4 +1,4 @@ -import 'package:flterm/src/rendering/terminal_frame_builder.dart'; +import 'package:flterm/src/rendering/frame_builder.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flterm/test/rendering/sprites_golden_test.dart b/packages/flterm/test/rendering/sprites_golden_test.dart index 29e55331..a4d63010 100644 --- a/packages/flterm/test/rendering/sprites_golden_test.dart +++ b/packages/flterm/test/rendering/sprites_golden_test.dart @@ -6,8 +6,8 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flterm/src/rendering/sprite/sprite_face.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -67,10 +67,10 @@ void main() { ...inclusiveRange(0x1CE90, 0x1CEAF), ]; - TerminalRenderCache renderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool atlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } void writeUtf8(Terminal terminal, String text) { @@ -84,8 +84,7 @@ void main() { double? maxWidth, double? maxHeight, }) { - applyTerminalTheme(terminal, theme); - final frameSource = TerminalFrameSource(terminal); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); final width = maxWidth ?? cols * metrics.cellWidth; final height = maxHeight ?? rows * metrics.cellHeight; @@ -100,7 +99,7 @@ void main() { theme: theme, metrics: metrics, offset: ViewportOffset.zero(), - renderCache: renderCache(), + atlasPool: atlasPool(), focused: true, onGeometryChanged: (_) {}, onViewportRowChanged: (_) {}, @@ -122,6 +121,7 @@ void main() { final terminalCols = cols * cellsPerSlot; final terminal = Terminal(cols: terminalCols, rows: rows); addTearDown(terminal.dispose); + applyTerminalTheme(terminal, theme); writeUtf8(terminal, codepointGridText(codepoints, cols, cellsPerSlot)); tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( @@ -271,6 +271,7 @@ void main() { const rows = 9; final terminal = Terminal(cols: cols, rows: rows); addTearDown(terminal.dispose); + applyTerminalTheme(terminal, theme); writeUtf8( terminal, 'Box: ┌────────┐ ╞═╪═╡\r\n' @@ -310,6 +311,7 @@ void main() { testWidgets('block cursor on sprite glyph', (tester) async { final terminal = Terminal(cols: cols, rows: rows); addTearDown(terminal.dispose); + applyTerminalTheme(terminal, theme); writeUtf8(terminal, 'AB─CD\x1b[1;3H'); tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( diff --git a/packages/flterm/test/rendering/terminal_frame_source_test.dart b/packages/flterm/test/rendering/terminal_frame_source_test.dart deleted file mode 100644 index 6b798a80..00000000 --- a/packages/flterm/test/rendering/terminal_frame_source_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -@Tags(['ffi']) -library; - -import 'package:flterm/src/rendering/terminal_frame_source.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart'; - -void main() { - group('TerminalFrameSource', () { - test('publishes terminal changes', () { - final terminal = Terminal(cols: 10, rows: 3); - final viewportChanges = ValueNotifier(0); - final source = TerminalFrameSource( - terminal, - viewportChanges: viewportChanges, - ); - addTearDown(terminal.dispose); - addTearDown(viewportChanges.dispose); - addTearDown(source.dispose); - var notifications = 0; - source.addListener(() => notifications++); - - terminal.write(Uint8List.fromList('hello'.codeUnits)); - - expect(notifications, 1); - }); - - test('publishes viewport changes', () { - final terminal = Terminal(cols: 10, rows: 3); - final viewportChanges = ValueNotifier(0); - final source = TerminalFrameSource( - terminal, - viewportChanges: viewportChanges, - ); - addTearDown(terminal.dispose); - addTearDown(viewportChanges.dispose); - addTearDown(source.dispose); - var notifications = 0; - source.addListener(() => notifications++); - - viewportChanges.value++; - - expect(notifications, 1); - }); - }); -} diff --git a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart index 3a930776..cecd5c38 100644 --- a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart @@ -7,7 +7,7 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/links/link_snapshot.dart'; import 'package:flterm/src/rendering.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -59,10 +59,10 @@ void main() { return (0.299 * c.r * 255 + 0.587 * c.g * 255 + 0.114 * c.b * 255) < 128; } - TerminalRenderCache renderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool atlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } void writeUtf8(Terminal terminal, String text) { @@ -80,7 +80,7 @@ void main() { bool blinkVisible = true, String preeditText = '', LinkSnapshot linkSnapshot = LinkSnapshot.empty, - ValueChanged? onGeometryChanged, + ValueChanged? onGeometryChanged, }) { final resolvedTheme = theme ?? @@ -89,7 +89,7 @@ void main() { ); applyTerminalTheme(terminal, resolvedTheme); selection?.applyTo(terminal); - final frameSource = TerminalFrameSource(terminal); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); final width = maxWidth ?? defaultCols * metrics.cellWidth; final height = maxHeight ?? defaultRows * metrics.cellHeight; @@ -105,7 +105,7 @@ void main() { theme: resolvedTheme, metrics: metrics, offset: ViewportOffset.zero(), - renderCache: renderCache(), + atlasPool: atlasPool(), focused: focused, blinkVisible: blinkVisible, preeditText: preeditText, diff --git a/packages/flterm/test/rendering/terminal_renderer_test.dart b/packages/flterm/test/rendering/terminal_renderer_test.dart index 7a6bc8b2..a91f4a41 100644 --- a/packages/flterm/test/rendering/terminal_renderer_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_test.dart @@ -7,7 +7,7 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering.dart'; import 'package:flterm/src/rendering/atlas/atlas_config.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,10 +28,10 @@ void main() { ); const defaultRows = 5; - TerminalRenderCache createRenderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool createAtlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } Widget wrap( @@ -45,14 +45,14 @@ void main() { bool focused = true, bool blinkVisible = true, double devicePixelRatio = 1, - ValueChanged? onGeometryChanged, + ValueChanged? onGeometryChanged, ValueChanged? onViewportRowChanged, - TerminalRenderCache? renderCache, + AtlasPool? atlasPool, ViewportOffset? offset, }) { selection?.applyTo(terminal); - renderCache ??= createRenderCache(); - final frameSource = TerminalFrameSource(terminal); + atlasPool ??= createAtlasPool(); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); final width = maxWidth ?? defaultCols * metrics.cellWidth; final height = maxHeight ?? defaultRows * metrics.cellHeight; @@ -68,7 +68,7 @@ void main() { metrics: metrics, surfacePadding: surfacePadding, offset: offset ?? ViewportOffset.zero(), - renderCache: renderCache, + atlasPool: atlasPool, devicePixelRatio: devicePixelRatio, focused: focused, blinkVisible: blinkVisible, @@ -139,7 +139,7 @@ void main() { testWidgets('geometry callback reports the complete measured surface', ( tester, ) async { - TerminalResizeEvent? reportedGeometry; + SurfaceMeasurement? reportedGeometry; await tester.pumpWidget( wrap( terminal, @@ -238,36 +238,36 @@ void main() { }); testWidgets('theme change triggers layout', (tester) async { - final renderCache = _TrackingRenderCache(); - addTearDown(renderCache.dispose); - await tester.pumpWidget(wrap(terminal, renderCache: renderCache)); + final atlasPool = _TrackingAtlasPool(); + addTearDown(atlasPool.dispose); + await tester.pumpWidget(wrap(terminal, atlasPool: atlasPool)); final box = tester.renderObject( find.byType(TerminalRenderer), ); expect(box.theme, TerminalTheme.dark()); - final acquisitionsBefore = renderCache.acquiredKeys.length; + final acquisitionsBefore = atlasPool.acquiredKeys.length; final light = TerminalTheme.light(); await tester.pumpWidget( - wrap(terminal, theme: light, renderCache: renderCache), + wrap(terminal, theme: light, atlasPool: atlasPool), ); expect(box.theme, light); - expect(renderCache.acquiredKeys, hasLength(acquisitionsBefore)); + expect(atlasPool.acquiredKeys, hasLength(acquisitionsBefore)); }); testWidgets('font theme change reacquires atlas', (tester) async { - final renderCache = _TrackingRenderCache(); - addTearDown(renderCache.dispose); - await tester.pumpWidget(wrap(terminal, renderCache: renderCache)); - final keyBefore = renderCache.acquiredKeys.last; + final atlasPool = _TrackingAtlasPool(); + addTearDown(atlasPool.dispose); + await tester.pumpWidget(wrap(terminal, atlasPool: atlasPool)); + final keyBefore = atlasPool.acquiredKeys.last; final larger = TerminalTheme.dark().copyWith(fontSize: 18); await tester.pumpWidget( - wrap(terminal, theme: larger, renderCache: renderCache), + wrap(terminal, theme: larger, atlasPool: atlasPool), ); await tester.pump(); - expect(renderCache.acquiredKeys.last, isNot(keyBefore)); + expect(atlasPool.acquiredKeys.last, isNot(keyBefore)); }); testWidgets('selection change does not trigger layout', (tester) async { @@ -346,11 +346,11 @@ void main() { }); } -class _TrackingRenderCache extends TerminalRenderCache { +class _TrackingAtlasPool extends AtlasPool { final acquiredKeys = []; @override - TerminalAtlasHandle acquireAtlas(AtlasConfig config) { + AtlasLease acquireAtlas(AtlasConfig config) { acquiredKeys.add(config); return super.acquireAtlas(config); } diff --git a/packages/flterm/test/rendering/transparent_background_golden_test.dart b/packages/flterm/test/rendering/transparent_background_golden_test.dart index d11d0c73..1fb71077 100644 --- a/packages/flterm/test/rendering/transparent_background_golden_test.dart +++ b/packages/flterm/test/rendering/transparent_background_golden_test.dart @@ -6,7 +6,7 @@ import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/rendering.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,10 +25,10 @@ void main() { const rows = 5; final sceneKey = GlobalKey(); - TerminalRenderCache renderCache() { - final cache = TerminalRenderCache(); - addTearDown(cache.dispose); - return cache; + AtlasPool atlasPool() { + final pool = AtlasPool(); + addTearDown(pool.dispose); + return pool; } void writeUtf8(Terminal terminal, String text) { @@ -50,7 +50,7 @@ void main() { final terminal = Terminal(cols: cols, rows: rows); addTearDown(terminal.dispose); applyTerminalTheme(terminal, theme); - final frameSource = TerminalFrameSource(terminal); + final frameSource = FrameSource(terminal); addTearDown(frameSource.dispose); writeUtf8(terminal, content); @@ -79,7 +79,7 @@ void main() { theme: theme, metrics: metrics, offset: ViewportOffset.zero(), - renderCache: renderCache(), + atlasPool: atlasPool(), focused: true, onGeometryChanged: (_) {}, onViewportRowChanged: (_) {}, diff --git a/packages/flterm/test/view/cursor_blink_test.dart b/packages/flterm/test/view/cursor_blink_test.dart new file mode 100644 index 00000000..b9590d5b --- /dev/null +++ b/packages/flterm/test/view/cursor_blink_test.dart @@ -0,0 +1,58 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flterm/src/view/cursor_blink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CursorBlink', () { + group('sync', () { + test('toggles after the enabled interval', () { + fakeAsync((async) { + final blink = CursorBlink(); + addTearDown(blink.dispose); + + blink.sync( + enabled: true, + interval: const Duration(milliseconds: 100), + ); + async.elapse(const Duration(milliseconds: 100)); + + expect(blink.value, isFalse); + }); + }); + + test('stays visible after blinking is disabled', () { + fakeAsync((async) { + final blink = CursorBlink(); + addTearDown(blink.dispose); + const interval = Duration(milliseconds: 100); + blink.sync(enabled: true, interval: interval); + async.elapse(interval); + + blink.sync(enabled: false, interval: interval); + async.elapse(interval * 2); + + expect(blink.value, isTrue); + }); + }); + + test('restarts the blink interval', () { + fakeAsync((async) { + final blink = CursorBlink(); + addTearDown(blink.dispose); + const interval = Duration(milliseconds: 100); + blink.sync(enabled: true, interval: interval); + async.elapse(const Duration(milliseconds: 75)); + + blink.sync(enabled: true, interval: interval); + async.elapse(const Duration(milliseconds: 75)); + + expect(blink.value, isTrue); + + async.elapse(const Duration(milliseconds: 25)); + + expect(blink.value, isFalse); + }); + }); + }); + }); +} diff --git a/packages/flterm/test/view/terminal_shortcut_scope_test.dart b/packages/flterm/test/view/shortcut_scope_test.dart similarity index 94% rename from packages/flterm/test/view/terminal_shortcut_scope_test.dart rename to packages/flterm/test/view/shortcut_scope_test.dart index 8688254b..c7babc6e 100644 --- a/packages/flterm/test/view/terminal_shortcut_scope_test.dart +++ b/packages/flterm/test/view/shortcut_scope_test.dart @@ -4,14 +4,14 @@ library; import 'dart:convert'; import 'package:flterm/src/controller/terminal_controller.dart'; -import 'package:flterm/src/view/terminal_shortcut_scope.dart'; +import 'package:flterm/src/view/shortcut_scope.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' show Position, Terminal; void main() { - group('TerminalShortcutScope', () { + group('ShortcutScope', () { late TerminalControllerImpl controller; setUp(() { @@ -35,7 +35,7 @@ void main() { }) { return Directionality( textDirection: TextDirection.ltr, - child: TerminalShortcutScope( + child: ShortcutScope( controller: controller, shortcuts: shortcuts, onPaste: onPaste, @@ -202,7 +202,7 @@ void main() { }); }); - group('TerminalShortcuts', () { + group('DefaultShortcuts', () { group('defaultsFor', () { void expectShortcutSet( Map shortcuts, { @@ -219,14 +219,14 @@ void main() { test('uses command shortcuts on Apple platforms', () { expectShortcutSet( - TerminalShortcuts.defaultsFor(TargetPlatform.macOS), + DefaultShortcuts.defaultsFor(TargetPlatform.macOS), copy: const SingleActivator(.keyC, meta: true), paste: const SingleActivator(.keyV, meta: true), selectAll: const SingleActivator(.keyA, meta: true), clear: const SingleActivator(.keyK, meta: true), ); expectShortcutSet( - TerminalShortcuts.defaultsFor(TargetPlatform.iOS), + DefaultShortcuts.defaultsFor(TargetPlatform.iOS), copy: const SingleActivator(.keyC, meta: true), paste: const SingleActivator(.keyV, meta: true), selectAll: const SingleActivator(.keyA, meta: true), @@ -236,7 +236,7 @@ void main() { test('uses control-shift shortcuts on Linux', () { expectShortcutSet( - TerminalShortcuts.defaultsFor(TargetPlatform.linux), + DefaultShortcuts.defaultsFor(TargetPlatform.linux), copy: const SingleActivator(.keyC, control: true, shift: true), paste: const SingleActivator(.keyV, control: true, shift: true), selectAll: const SingleActivator(.keyA, control: true, shift: true), @@ -246,14 +246,14 @@ void main() { test('uses control shortcuts on Windows and Android', () { expectShortcutSet( - TerminalShortcuts.defaultsFor(TargetPlatform.windows), + DefaultShortcuts.defaultsFor(TargetPlatform.windows), copy: const SingleActivator(.keyC, control: true), paste: const SingleActivator(.keyV, control: true), selectAll: const SingleActivator(.keyA, control: true), clear: const SingleActivator(.keyK, control: true), ); expectShortcutSet( - TerminalShortcuts.defaultsFor(TargetPlatform.android), + DefaultShortcuts.defaultsFor(TargetPlatform.android), copy: const SingleActivator(.keyC, control: true), paste: const SingleActivator(.keyV, control: true), selectAll: const SingleActivator(.keyA, control: true), diff --git a/packages/flterm/test/view/terminal_cursor_blink_test.dart b/packages/flterm/test/view/terminal_cursor_blink_test.dart deleted file mode 100644 index ea9e8c10..00000000 --- a/packages/flterm/test/view/terminal_cursor_blink_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flterm/src/view/terminal_cursor_blink.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('toggles while enabled and resets visible when disabled', ( - tester, - ) async { - final blink = TerminalCursorBlink(); - - blink.sync(enabled: true, interval: const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); - expect(blink.value, isFalse); - - blink.sync(enabled: false, interval: const Duration(milliseconds: 100)); - expect(blink.value, isTrue); - await tester.pump(const Duration(milliseconds: 200)); - expect(blink.value, isTrue); - blink.dispose(); - }); - - testWidgets('sync restarts the blink interval', (tester) async { - final blink = TerminalCursorBlink(); - const interval = Duration(milliseconds: 100); - - blink.sync(enabled: true, interval: interval); - await tester.pump(const Duration(milliseconds: 75)); - blink.sync(enabled: true, interval: interval); - await tester.pump(const Duration(milliseconds: 75)); - expect(blink.value, isTrue); - await tester.pump(const Duration(milliseconds: 25)); - expect(blink.value, isFalse); - blink.dispose(); - }); -} diff --git a/packages/flterm/test/view/terminal_scroll_controller_test.dart b/packages/flterm/test/view/terminal_scroll_controller_test.dart index 43ff4118..6b62fcd6 100644 --- a/packages/flterm/test/view/terminal_scroll_controller_test.dart +++ b/packages/flterm/test/view/terminal_scroll_controller_test.dart @@ -34,10 +34,10 @@ void main() { }); group('createScrollPosition', () { - testWidgets('returns TerminalScrollPosition', (tester) async { + testWidgets('returns ScrollbackPosition', (tester) async { await tester.pumpWidget(buildScrollable(controller)); - expect(controller.position, isA()); + expect(controller.position, isA()); }); }); @@ -47,7 +47,7 @@ void main() { controller.activeScreen = .alternate; - final position = controller.position as TerminalScrollPosition; + final position = controller.position as ScrollbackPosition; expect(position.activeScreen, TerminalScreen.alternate); controller.activeScreen = .primary; @@ -56,7 +56,7 @@ void main() { }); }); - group('TerminalScrollPosition', () { + group('ScrollbackPosition', () { late TerminalScrollController controller; setUp(() => controller = TerminalScrollController()); @@ -95,7 +95,6 @@ void main() { controller.activeScreen = .alternate; await tester.pumpWidget(buildScrollable(controller)); - expect(controller.position.pixels, 0); controller.jumpTo(9999); await tester.pump(); diff --git a/packages/flterm/test/view/terminal_view_attachment_test.dart b/packages/flterm/test/view/terminal_view_attachment_test.dart deleted file mode 100644 index b3c67be3..00000000 --- a/packages/flterm/test/view/terminal_view_attachment_test.dart +++ /dev/null @@ -1,162 +0,0 @@ -@Tags(['ffi']) -library; - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flterm/src/controller/terminal_controller.dart'; -import 'package:flterm/src/foundation.dart'; -import 'package:flterm/src/view/terminal_view_attachment.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' show Mods, RgbColor, TerminalScreen; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('TerminalViewAttachment', () { - late TerminalControllerImpl controller; - late TerminalViewAttachment attachment; - - setUp(() { - controller = TerminalControllerImpl(); - attachment = TerminalViewAttachment(controller); - }); - - tearDown(() { - attachment.dispose(); - controller.dispose(); - }); - - RgbColor rgb(Color color) => RgbColor( - (color.r * 255).round().clamp(0, 255), - (color.g * 255).round().clamp(0, 255), - (color.b * 255).round().clamp(0, 255), - ); - - test('exposes controller terminal state without changing ownership', () { - expect(attachment.terminal, same(controller.terminal)); - expect(attachment.virtualMods, const Mods.none()); - }); - - test('rejects a second active view attachment', () { - expect( - () => TerminalViewAttachment(controller), - throwsA(isA()), - ); - }); - - test('stale attachment disposal does not detach a newer view', () { - final first = attachment; - first.dispose(); - final second = TerminalViewAttachment(controller); - attachment = second; - - first.dispose(); - - expect( - () => TerminalViewAttachment(controller), - throwsA(isA()), - ); - }); - - test('projects only interaction changes', () { - var notifications = 0; - attachment.interaction.addListener(() => notifications++); - - controller.toggleMod(const Mods.ctrl()); - - expect(notifications, 0); - - controller.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); - - expect(notifications, 1); - expect( - attachment.interaction.value.activeScreen, - TerminalScreen.alternate, - ); - }); - - test('publishes controller changes without broad interaction rebuilds', () { - var attachmentNotifications = 0; - var interactionNotifications = 0; - attachment.addListener(() => attachmentNotifications++); - attachment.interaction.addListener(() => interactionNotifications++); - - controller.toggleMod(const Mods.ctrl()); - - expect(attachmentNotifications, 1); - expect(interactionNotifications, 0); - }); - - test('applies view theme colors to the terminal session', () { - final theme = TerminalTheme.dark(); - - attachment.applyTheme(theme); - - expect(attachment.terminal.foreground, rgb(theme.foreground)); - expect(attachment.terminal.background, rgb(theme.background)); - expect(attachment.terminal.palette[1], rgb(theme.palette[1])); - }); - - test('applies viewport row intents to the terminal session', () { - controller.write( - Uint8List.fromList( - List.filled(40, 'scrollback row\r\n').join().codeUnits, - ), - ); - - attachment.handleViewportRowChanged(0); - - expect(attachment.terminal.scrollbar.offset, 0); - }); - - testWidgets('attaches and detaches view services locally', (tester) async { - final focusNode = _InspectableFocusNode(); - final scrollController = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(scrollController.dispose); - - await tester.pumpWidget( - Focus(focusNode: focusNode, child: const SizedBox()), - ); - attachment.attach(focusNode, scrollController, viewId: 0); - - focusNode.requestFocus(); - await tester.pump(); - - expect(focusNode.hasFocus, isTrue); - - attachment.detach(); - - expect(attachment.input.preeditText, isEmpty); - }); - - testWidgets('does not duplicate a focus listener when reattached', ( - tester, - ) async { - final focusNode = _InspectableFocusNode(); - final scrollController = ScrollController(); - addTearDown(focusNode.dispose); - addTearDown(scrollController.dispose); - - await tester.pumpWidget( - Focus(focusNode: focusNode, child: const SizedBox()), - ); - - final initiallyHasListeners = focusNode.hasFocusListeners; - attachment.attach(focusNode, scrollController, viewId: 0); - final hasListenersAfterAttach = focusNode.hasFocusListeners; - - attachment.attach(focusNode, scrollController, viewId: 1); - - expect(focusNode.hasFocusListeners, hasListenersAfterAttach); - attachment.detach(); - expect(focusNode.hasFocusListeners, initiallyHasListeners); - }); - }); -} - -final class _InspectableFocusNode extends FocusNode { - bool get hasFocusListeners => hasListeners; -} diff --git a/packages/flterm/test/view/terminal_view_test.dart b/packages/flterm/test/view/terminal_view_test.dart index e53db1bd..37fd5d4e 100644 --- a/packages/flterm/test/view/terminal_view_test.dart +++ b/packages/flterm/test/view/terminal_view_test.dart @@ -16,10 +16,10 @@ import 'package:flutter/foundation.dart' import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' hide ColorScheme, KeyEvent; import 'package:libghostty/libghostty.dart' as vt show ColorScheme, ColorSchemeReportEncode; +import 'package:libghostty/libghostty.dart' hide ColorScheme, KeyEvent; import 'package:material_ui/material_ui.dart'; extension _SelectionEdges on Selection { @@ -33,8 +33,6 @@ extension _SelectionEdges on Selection { return start.row != end.row ? start.row < end.row : start.col <= end.col; } - int get startCol => _forward ? _startPoint.col : _startPoint.col + 1; - int get endCol => _forward ? _endPoint.col + 1 : _endPoint.col; TerminalSelectionShape get mode { @@ -84,6 +82,16 @@ void main() { ); } + ({int height, int width}) physicalSizeReport(List output) { + final match = RegExp( + '\x1b\\[4;(\\d+);(\\d+)t', + ).firstMatch(decodeOutput(output))!; + return ( + height: int.parse(match.group(1)!), + width: int.parse(match.group(2)!), + ); + } + Future sendTextInputDeltas(List> deltas) async { final messageBytes = const JSONMessageCodec().encodeMessage({ 'method': 'TextInputClient.updateEditingStateWithDeltas', @@ -266,7 +274,7 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); - testWidgets('creates an isolated render cache without explicit scope', ( + testWidgets('creates an isolated atlas pool without explicit scope', ( tester, ) async { final controller2 = TerminalController(); @@ -345,6 +353,33 @@ void main() { expect(renderer(tester).devicePixelRatio, tester.view.devicePixelRatio); }); + testWidgets('DPR changes recommit physical geometry', (tester) async { + final output = []; + controller.onOutput = output.add; + tester.view + ..devicePixelRatio = 1 + ..physicalSize = const Size(800, 480); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(wrapInApp(controller: controller)); + await tester.pumpAndSettle(); + controller.write(Uint8List.fromList(utf8.encode('\x1b[14t'))); + final initial = physicalSizeReport(output); + output.clear(); + + tester.view + ..devicePixelRatio = 2 + ..physicalSize = const Size(1600, 960); + await tester.pumpAndSettle(); + controller.write(Uint8List.fromList(utf8.encode('\x1b[14t'))); + + expect(physicalSizeReport(output), ( + height: initial.height * 2, + width: initial.width * 2, + )); + }); + testWidgets('tap to focus', (tester) async { final focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -1089,19 +1124,43 @@ void main() { expect(expected.top, greaterThan(0)); }); - testWidgets('unmount clears focus state', (tester) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - await tester.pumpWidget( - wrapInApp(controller: controller, focusNode: focusNode), - ); - focusNode.requestFocus(); - await tester.pump(); + group('unmount', () { + testWidgets('clears focus state', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + wrapInApp(controller: controller, focusNode: focusNode), + ); + focusNode.requestFocus(); + await tester.pump(); - await tester.pumpWidget(const MaterialApp(home: SizedBox())); - await tester.pumpAndSettle(); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pumpAndSettle(); - expect(focusNode.hasFocus, isFalse); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('leaves the application-owned controller usable', ( + tester, + ) async { + final output = []; + controller.onOutput = output.add; + await tester.pumpWidget(wrapInApp(controller: controller)); + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + controller.sendText('ready'); + + expect(decodeOutput(output), 'ready'); + }); + + testWidgets('releases the controller view attachment', (tester) async { + await tester.pumpWidget(wrapInApp(controller: controller)); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + + await tester.pumpWidget(wrapInApp(controller: controller)); + + expect(find.byType(TerminalView), findsOneWidget); + }); }); testWidgets('changing theme updates metrics', (tester) async { @@ -1706,7 +1765,7 @@ void main() { final position = scrollController.position; expect( - (position as TerminalScrollPosition).activeScreen, + (position as ScrollbackPosition).activeScreen, TerminalScreen.alternate, ); expect(position.minScrollExtent, double.negativeInfinity); @@ -2954,79 +3013,6 @@ void main() { }); }); - group('mouse selection', () { - Future mouseDown(WidgetTester tester, Offset pos) { - return tester.startGesture(pos, kind: PointerDeviceKind.mouse); - } - - Future tapMouse( - WidgetTester tester, - Offset position, { - int count = 1, - }) async { - for (var i = 0; i < count; i++) { - final gesture = await mouseDown(tester, position); - await gesture.up(); - } - } - - testWidgets('double click selects word', (tester) async { - writeUtf8(controller, 'hello world'); - await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), - ); - await tester.pumpAndSettle(); - - final topLeft = tester.getTopLeft(find.byType(TerminalView)); - final clickPos = topLeft + const Offset(20, 8); - - await tapMouse(tester, clickPos, count: 2); - await tester.pump(); - - expect(controller.hasSelection, isTrue); - expect(controller.selectedText(), contains('hello')); - }); - - testWidgets('triple click selects entire line', (tester) async { - writeUtf8(controller, 'hello world'); - await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), - ); - await tester.pumpAndSettle(); - - final topLeft = tester.getTopLeft(find.byType(TerminalView)); - final clickPos = topLeft + const Offset(20, 8); - - await tapMouse(tester, clickPos, count: 3); - await tester.pump(); - - final sel = activeSelection(controller); - expect(sel, isNotNull); - expect(sel!.startCol, 0); - expect(controller.selectedText().length, greaterThan('hello'.length)); - }); - - testWidgets('mouse drag creates selection', (tester) async { - writeUtf8(controller, 'hello world'); - await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), - ); - await tester.pumpAndSettle(); - - final topLeft = tester.getTopLeft(find.byType(TerminalView)); - final start = topLeft + const Offset(10, 8); - final end = topLeft + const Offset(100, 8); - - final gesture = await mouseDown(tester, start); - await gesture.moveTo(end); - await gesture.up(); - await tester.pump(); - - expect(controller.hasSelection, isTrue); - expect(controller.selectedText(), isNotEmpty); - }); - }); - group('padding', () { testWidgets('padding reduces reported grid size', (tester) async { final cols = []; diff --git a/packages/flterm/test/view/view_attachment_test.dart b/packages/flterm/test/view/view_attachment_test.dart new file mode 100644 index 00000000..1a5d072f --- /dev/null +++ b/packages/flterm/test/view/view_attachment_test.dart @@ -0,0 +1,217 @@ +@Tags(['ffi']) +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flterm/src/controller/terminal_controller.dart'; +import 'package:flterm/src/foundation.dart'; +import 'package:flterm/src/view/view_attachment.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' show Mods, RgbColor, TerminalScreen; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ViewAttachment', () { + late TerminalControllerImpl controller; + late ViewAttachment attachment; + + setUp(() { + controller = TerminalControllerImpl(); + attachment = ViewAttachment(controller); + }); + + tearDown(() { + attachment.dispose(); + controller.dispose(); + }); + + RgbColor rgb(Color color) => RgbColor( + (color.r * 255).round().clamp(0, 255), + (color.g * 255).round().clamp(0, 255), + (color.b * 255).round().clamp(0, 255), + ); + + group('ownership', () { + test('rejects a second active view attachment', () { + expect(() => ViewAttachment(controller), throwsA(isA())); + }); + + test('stale attachment disposal does not detach a newer view', () { + final first = attachment; + first.dispose(); + final second = ViewAttachment(controller); + attachment = second; + + first.dispose(); + + expect(() => ViewAttachment(controller), throwsA(isA())); + }); + }); + + group('interaction state', () { + test('starts without virtual modifiers', () { + expect(attachment.virtualMods, const Mods.none()); + }); + + test('projects only interaction changes', () { + var notifications = 0; + attachment.interaction.addListener(() => notifications++); + + controller.toggleMod(const Mods.ctrl()); + + expect(notifications, 0); + + controller.write(Uint8List.fromList(utf8.encode('\x1b[?1049h'))); + + expect(notifications, 1); + expect( + attachment.interaction.value.activeScreen, + TerminalScreen.alternate, + ); + }); + + test('compares terminal modes by value', () { + const first = ViewInteractionState( + activeScreen: .primary, + mouseTracking: .none, + alternateScroll: false, + ); + const second = ViewInteractionState( + activeScreen: .primary, + mouseTracking: .none, + alternateScroll: false, + ); + + expect(first, second); + }); + + test('produces equal hashes for equal terminal modes', () { + const first = ViewInteractionState( + activeScreen: .primary, + mouseTracking: .none, + alternateScroll: false, + ); + const second = ViewInteractionState( + activeScreen: .primary, + mouseTracking: .none, + alternateScroll: false, + ); + + expect(first.hashCode, second.hashCode); + }); + + test('publishes broad changes without interaction notifications', () { + var attachmentNotifications = 0; + var interactionNotifications = 0; + attachment.addListener(() => attachmentNotifications++); + attachment.interaction.addListener(() => interactionNotifications++); + + controller.toggleMod(const Mods.ctrl()); + + expect(attachmentNotifications, 1); + expect(interactionNotifications, 0); + }); + }); + + group('applyTheme', () { + test('applies view colors to the terminal session', () { + final theme = TerminalTheme.dark(); + + attachment.applyTheme(theme); + + expect(attachment.terminal.foreground, rgb(theme.foreground)); + expect(attachment.terminal.background, rgb(theme.background)); + expect(attachment.terminal.palette[1], rgb(theme.palette[1])); + }); + }); + + group('handleViewportRowChanged', () { + test('applies viewport row intents to the terminal session', () { + controller.write( + Uint8List.fromList( + List.filled(40, 'scrollback row\r\n').join().codeUnits, + ), + ); + + attachment.handleViewportRowChanged(0); + + expect(attachment.terminal.scrollbar.offset, 0); + }); + }); + + group('attach and detach', () { + testWidgets('owns view services locally', (tester) async { + final focusNode = _InspectableFocusNode(); + final scrollController = ScrollController(); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Focus(focusNode: focusNode, child: const SizedBox()), + ); + attachment.attach(focusNode, scrollController, viewId: 0); + + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + + attachment.detach(); + + expect(attachment.input.preeditText, isEmpty); + }); + + testWidgets('does not duplicate a focus listener when reattached', ( + tester, + ) async { + final focusNode = _InspectableFocusNode(); + final scrollController = ScrollController(); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Focus(focusNode: focusNode, child: const SizedBox()), + ); + + final initiallyHasListeners = focusNode.hasFocusListeners; + attachment.attach(focusNode, scrollController, viewId: 0); + final hasListenersAfterAttach = focusNode.hasFocusListeners; + + attachment.attach(focusNode, scrollController, viewId: 1); + + expect(focusNode.hasFocusListeners, hasListenersAfterAttach); + attachment.detach(); + expect(focusNode.hasFocusListeners, initiallyHasListeners); + }); + }); + + group('dispose', () { + test('leaves the application-owned controller usable', () { + final output = []; + controller.onOutput = output.add; + + attachment.dispose(); + controller.sendText('ready'); + + expect(utf8.decode(output.single), 'ready'); + }); + + test('stops publishing controller changes', () { + var notifications = 0; + attachment.addListener(() => notifications++); + + attachment.dispose(); + controller.toggleMod(const Mods.ctrl()); + + expect(notifications, 0); + }); + }); + }); +} + +final class _InspectableFocusNode extends FocusNode { + bool get hasFocusListeners => hasListeners; +} diff --git a/packages/flterm/tool/benchmarks/README.md b/packages/flterm/tool/benchmarks/README.md index 2a6ad327..e9b9cc23 100644 --- a/packages/flterm/tool/benchmarks/README.md +++ b/packages/flterm/tool/benchmarks/README.md @@ -90,7 +90,7 @@ glyphs and also exercises bold text, combining text, and emoji. The bundled fonts keep glyph selection stable across machines. `first terminal frame` records at least 30 independent first frames from fresh -renderer and render-cache instances. Terminal state and fonts are prepared +renderer and atlas-pool instances. Terminal state and fonts are prepared first. This measures flterm's first presentation, not Flutter process startup. ## Run locally diff --git a/packages/flterm/tool/benchmarks/frame/harness.dart b/packages/flterm/tool/benchmarks/frame/harness.dart index 6e23571b..5e5d8b4f 100644 --- a/packages/flterm/tool/benchmarks/frame/harness.dart +++ b/packages/flterm/tool/benchmarks/frame/harness.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; -import 'package:flterm/src/rendering/terminal_frame_source.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; +import 'package:flterm/src/rendering/frame_source.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -46,13 +46,13 @@ final class FrameBenchmarkHarness { ( terminal: Terminal(cols: benchmarkColumns, rows: benchmarkRows) ..write(state), - cache: TerminalRenderCache(), + atlasPool: AtlasPool(), ), ]; final frameSources = [ - for (final resource in resources) TerminalFrameSource(resource.terminal), + for (final resource in resources) FrameSource(resource.terminal), ]; - final retainedAtlases = []; + final retainedAtlasLeases = []; try { await _tester.pumpWidget(const SizedBox.shrink()); await _binding.watchPerformance(() async { @@ -63,13 +63,13 @@ final class FrameBenchmarkHarness { BenchmarkTerminalSurface( key: ValueKey(sample), frameSource: frameSources[sample], - cache: resource.cache, + atlasPool: resource.atlasPool, ), ), ); _binding.scheduleFrame(); await _binding.endOfFrame; - retainedAtlases.add(retainBenchmarkAtlas(resource.cache)); + retainedAtlasLeases.add(retainBenchmarkAtlas(resource.atlasPool)); } }, reportKey: _firstFrameReportKey); final summary = Map.from( @@ -81,14 +81,14 @@ final class FrameBenchmarkHarness { ); } finally { await _tester.pumpWidget(const SizedBox.shrink()); - for (final handle in retainedAtlases) { - handle.release(); + for (final lease in retainedAtlasLeases) { + lease.release(); } for (final source in frameSources) { source.dispose(); } for (final resource in resources) { - resource.cache.dispose(); + resource.atlasPool.dispose(); resource.terminal.dispose(); } } @@ -106,15 +106,15 @@ final class FrameBenchmarkHarness { List? updates, }) async { final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); - final frameSource = TerminalFrameSource(terminal); - final cache = TerminalRenderCache(); + final frameSource = FrameSource(terminal); + final atlasPool = AtlasPool(); addTearDown(terminal.dispose); addTearDown(frameSource.dispose); - addTearDown(cache.dispose); + addTearDown(atlasPool.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(frameSource: frameSource, cache: cache), + BenchmarkTerminalSurface(frameSource: frameSource, atlasPool: atlasPool), ); terminal.write(TerminalBenchmarkFixture.fullFrames(count: 1).single); await _tester.pump(); @@ -144,14 +144,14 @@ final class FrameBenchmarkHarness { required List updates, }) async { final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); - final frameSource = TerminalFrameSource(terminal); - final cache = TerminalRenderCache(); + final frameSource = FrameSource(terminal); + final atlasPool = AtlasPool(); addTearDown(terminal.dispose); addTearDown(frameSource.dispose); - addTearDown(cache.dispose); + addTearDown(atlasPool.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(frameSource: frameSource, cache: cache), + BenchmarkTerminalSurface(frameSource: frameSource, atlasPool: atlasPool), ); final summary = await _capture(() async { diff --git a/packages/flterm/tool/benchmarks/frame/render_environment.dart b/packages/flterm/tool/benchmarks/frame/render_environment.dart index 8a1baa2b..232760db 100644 --- a/packages/flterm/tool/benchmarks/frame/render_environment.dart +++ b/packages/flterm/tool/benchmarks/frame/render_environment.dart @@ -4,8 +4,8 @@ import 'package:crypto/crypto.dart' show sha256; import 'package:flterm/src/foundation/cell_metrics.dart'; import 'package:flterm/src/foundation/terminal_theme.dart'; import 'package:flterm/src/rendering/atlas/atlas_config.dart'; -import 'package:flterm/src/rendering/terminal_frame_source.dart'; -import 'package:flterm/src/rendering/terminal_render_cache.dart'; +import 'package:flterm/src/rendering/atlas_pool.dart'; +import 'package:flterm/src/rendering/frame_source.dart'; import 'package:flterm/src/rendering/terminal_renderer.dart'; import 'package:flutter/rendering.dart' show ViewportOffset; import 'package:flutter/services.dart'; @@ -71,13 +71,13 @@ String benchmarkFontDigest({ /// Fixed terminal surface shared by every rendering workload. final class BenchmarkTerminalSurface extends StatelessWidget { - final TerminalFrameSource frameSource; - final TerminalRenderCache cache; + final FrameSource frameSource; + final AtlasPool atlasPool; const BenchmarkTerminalSurface({ super.key, required this.frameSource, - required this.cache, + required this.atlasPool, }); @override @@ -95,7 +95,7 @@ final class BenchmarkTerminalSurface extends StatelessWidget { metrics: _metrics, offset: ViewportOffset.zero(), focused: true, - renderCache: cache, + atlasPool: atlasPool, onGeometryChanged: (geometry) => frameSource.terminal.resize( cols: geometry.cols, rows: geometry.rows, @@ -113,6 +113,6 @@ final class BenchmarkTerminalSurface extends StatelessWidget { } /// Keeps an already-populated atlas alive after its renderer is detached. -TerminalAtlasHandle retainBenchmarkAtlas(TerminalRenderCache cache) { - return cache.acquireAtlas(_atlasConfig); +AtlasLease retainBenchmarkAtlas(AtlasPool atlasPool) { + return atlasPool.acquireAtlas(_atlasConfig); } From 1ce59439c9d2164fb181a060ddb60a7b8d66cf69 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Mon, 17 Aug 2026 13:27:10 +0300 Subject: [PATCH 07/22] refactor(libghostty): reorganize public API and bindings --- .../lib/src/controller/kitty_png_decoder.dart | 6 +- .../controller/terminal_controller_impl.dart | 2 +- .../interaction/selection_gesture_driver.dart | 4 - .../controller/terminal_controller_test.dart | 67 +- .../kitty_graphics_painter_golden_test.dart | 2 +- .../rendering/terminal_renderer_test.dart | 15 +- packages/libghostty/lib/libghostty.dart | 199 +- .../lib/src/{impl => api}/build_info.dart | 10 +- .../lib/src/{impl => api}/color.dart | 20 +- .../lib/src/{impl => api}/encode.dart | 34 +- .../src/{impl => api}/key/key_encoder.dart | 69 +- .../lib/src/{impl => api}/key/key_event.dart | 91 +- .../{impl => api}/key/kitty_key_flags.dart | 7 +- .../lib/src/{impl => api}/key/mods.dart | 14 +- .../{impl => api}/mouse/mouse_encoder.dart | 85 +- .../src/{impl => api}/mouse/mouse_event.dart | 67 +- .../lib/src/{impl => api}/osc_parser.dart | 71 +- .../lib/src/{impl => api}/paste.dart | 6 +- .../lib/src/{impl => api}/sgr_parser.dart | 57 +- .../libghostty/lib/src/{impl => api}/sys.dart | 69 +- .../{impl => api}/terminal/cell_iterator.dart | 97 +- .../src/{impl => api}/terminal/formatter.dart | 55 +- .../src/{impl => api}/terminal/grid_ref.dart | 43 +- .../lib/src/api/terminal/kitty_graphics.dart | 184 + .../{impl => api}/terminal/render_state.dart | 81 +- .../{impl => api}/terminal/row_iterator.dart | 81 +- .../src/{impl => api}/terminal/selection.dart | 50 +- .../src/api/terminal/selection_gesture.dart | 378 ++ .../src/{impl => api}/terminal/terminal.dart | 381 +- .../{impl => api}/terminal/terminal_mode.dart | 9 +- .../terminal/tracked_grid_ref.dart | 56 +- .../lib/src/{impl => api}/unicode.dart | 4 +- .../libghostty/lib/src/bindings/bindings.dart | 69 +- packages/libghostty/lib/src/bindings/ffi.dart | 25 + .../lib/src/bindings/formatter/ffi.dart | 126 + .../lib/src/bindings/formatter/formatter.dart | 18 + .../lib/src/bindings/formatter/wasm.dart | 203 + .../lib/src/bindings/interface.dart | 531 -- .../libghostty/lib/src/bindings/key/ffi.dart | 229 + .../libghostty/lib/src/bindings/key/key.dart | 36 + .../libghostty/lib/src/bindings/key/wasm.dart | 273 + .../lib/src/bindings/kitty_graphics/ffi.dart | 345 ++ .../kitty_graphics/kitty_graphics.dart | 39 + .../lib/src/bindings/kitty_graphics/wasm.dart | 395 ++ .../lib/src/bindings/mouse/ffi.dart | 218 + .../lib/src/bindings/mouse/mouse.dart | 37 + .../lib/src/bindings/mouse/wasm.dart | 294 + .../lib/src/bindings/native/native.dart | 4578 --------------- .../lib/src/bindings/parser/ffi.dart | 191 + .../lib/src/bindings/parser/parser.dart | 23 + .../lib/src/bindings/parser/wasm.dart | 236 + .../lib/src/bindings/render/ffi.dart | 1495 +++++ .../lib/src/bindings/render/render.dart | 121 + .../lib/src/bindings/render/wasm.dart | 2097 +++++++ .../lib/src/bindings/result_helpers.dart | 47 + .../lib/src/bindings/selection/ffi.dart | 845 +++ .../lib/src/bindings/selection/selection.dart | 146 + .../lib/src/bindings/selection/wasm.dart | 944 +++ .../lib/src/bindings/system/ffi.dart | 212 + .../lib/src/bindings/system/sys.dart | 9 + .../lib/src/bindings/system/wasm.dart | 157 + .../lib/src/bindings/terminal/ffi.dart | 1297 +++++ .../lib/src/bindings/terminal/terminal.dart | 176 + .../lib/src/bindings/terminal/wasm.dart | 1640 ++++++ .../libghostty/lib/src/bindings/types.dart | 72 + .../lib/src/bindings/types/aliases.dart | 203 - .../bindings/types/mouse_encoder_size.dart | 44 - .../lib/src/bindings/types/position.dart | 35 - .../lib/src/bindings/types/result.dart | 91 - .../lib/src/bindings/types/types.dart | 8 - .../lib/src/bindings/utility/ffi.dart | 477 ++ .../lib/src/bindings/utility/utility.dart | 46 + .../lib/src/bindings/utility/wasm.dart | 590 ++ .../libghostty/lib/src/bindings/wasm.dart | 4 + .../lib/src/bindings/wasm/allocator.dart | 64 + .../lib/src/bindings/wasm/bootstrap.dart | 122 + .../lib/src/bindings/wasm/bootstrap_stub.dart | 9 + .../lib/src/bindings/wasm/layouts.dart | 151 +- .../lib/src/bindings/wasm/memory.dart | 73 +- .../lib/src/bindings/wasm/scratch.dart | 453 ++ .../lib/src/bindings/wasm/wasm.dart | 5117 ----------------- .../src/{ffi => generated}/libghostty.g.dart | 1 - .../libghostty_enums.g.dart | 0 .../{ffi => generated}/libghostty_wasm.g.dart | 0 .../lib/src/impl/terminal/cursor.dart | 69 - .../lib/src/impl/terminal/kitty_graphics.dart | 347 -- .../src/impl/terminal/selection_gesture.dart | 268 - packages/libghostty/lib/src/listenable.dart | 15 +- .../libghostty/lib/src/types/aliases.dart | 57 + .../lib/src/{bindings => }/types/color.dart | 44 +- .../libghostty/lib/src/types/exceptions.dart | 101 + .../formatter.dart} | 41 +- .../libghostty/lib/src/types/geometry.dart | 208 + .../lib/src/types/kitty_graphics.dart | 207 + .../types/models.dart => types/render.dart} | 233 +- .../libghostty/lib/src/types/terminal.dart | 196 + packages/libghostty/lib/src/types/types.dart | 9 + .../test/{impl => api}/build_info_test.dart | 7 +- .../test/{impl => api}/color_util_test.dart | 33 +- .../test/{impl => api}/encode_test.dart | 7 +- .../test/{impl => api}/formatter_test.dart | 32 +- .../{wasm => api/key}/key_encoder_test.dart | 53 +- .../{wasm => api/key}/key_event_test.dart | 34 +- .../key/kitty_key_flags_test.dart | 0 .../test/{impl => api}/key/mods_test.dart | 0 .../mouse/mouse_encoder_test.dart | 46 +- .../{impl => api}/mouse/mouse_event_test.dart | 29 +- .../parsing => api}/osc_parser_test.dart | 30 +- .../test/{impl => api}/paste_test.dart | 7 +- .../{impl => api}/sgr_attribute_test.dart | 0 .../parsing => api}/sgr_parser_test.dart | 28 +- .../test/{impl => api}/sys_test.dart | 19 +- .../terminal/cell_iterator_test.dart | 29 +- .../{impl => api}/terminal/grid_ref_test.dart | 27 +- .../terminal/helpers/cell_reader.dart | 0 .../terminal/helpers/terminal_dump.dart | 0 .../terminal_integration_test.dart | 6 +- .../terminal/kitty_graphics_test.dart | 8 +- .../kitty_temp_file_directory_ffi_test.dart | 54 + .../kitty_temp_file_directory_wasm_test.dart | 34 + .../test/api/terminal/render_state_test.dart | 232 + .../terminal/row_iterator_test.dart | 43 +- .../api/terminal/selection_gesture_test.dart | 412 ++ .../terminal/selection_test.dart | 7 +- .../terminal/terminal_colors_test.dart | 7 +- .../terminal/terminal_mode_test.dart | 8 +- .../terminal/terminal_scrollbar_test.dart | 7 +- .../{impl => api}/terminal/terminal_test.dart | 346 +- .../terminal/tracked_grid_ref_test.dart | 38 +- .../test/{impl => api}/unicode_test.dart | 7 +- .../test/bindings/bindings_native_test.dart | 1588 ----- .../test/bindings/formatter/ffi_test.dart | 28 + .../test/bindings/key/ffi_test.dart | 68 + .../bindings/kitty_graphics/ffi_test.dart | 29 + .../test/bindings/mouse/ffi_test.dart | 62 + .../test/bindings/parser/ffi_test.dart | 53 + .../test/bindings/render/ffi_test.dart | 44 + .../test/bindings/result_helpers_test.dart | 158 + .../test/bindings/selection/ffi_test.dart | 48 + .../test/bindings/system/ffi_test.dart | 35 + .../test/bindings/terminal/ffi_test.dart | 100 + .../test/bindings/utility/ffi_test.dart | 93 + .../test/bindings/wasm/bootstrap_test.dart | 55 + .../test/bindings/wasm/memory_test.dart | 117 + .../test/bindings/wasm/scratch_test.dart | 390 ++ packages/libghostty/test/exceptions_test.dart | 6 +- .../test/{wasm => }/helpers/asset_server.dart | 0 packages/libghostty/test/helpers/setup.dart | 7 + .../libghostty/test/helpers/setup_vm.dart | 1 + .../setup.dart => helpers/setup_wasm.dart} | 4 +- .../test/impl/key/key_encoder_test.dart | 106 - .../test/impl/key/key_event_test.dart | 78 - .../libghostty/test/impl/osc_parser_test.dart | 65 - .../libghostty/test/impl/sgr_parser_test.dart | 99 - .../impl/terminal/cell_iterator_test.dart | 193 - .../test/impl/terminal/cell_style_test.dart | 56 - .../test/impl/terminal/cursor_test.dart | 96 - .../test/impl/terminal/position_test.dart | 30 - .../test/impl/terminal/row_iterator_test.dart | 134 - .../impl/terminal/selection_gesture_test.dart | 166 - .../impl/terminal/tracked_grid_ref_test.dart | 171 - .../libghostty/test/types/formatter_test.dart | 32 + .../libghostty/test/types/geometry_test.dart | 157 + .../test/types/kitty_graphics_test.dart | 97 + .../libghostty/test/types/render_test.dart | 125 + .../libghostty/test/types/terminal_test.dart | 60 + .../test/wasm/bindings_wasm_test.dart | 1613 ------ .../wasm/impl/terminal/grid_ref_test.dart | 173 - packages/libghostty/test/wasm/paste_test.dart | 47 - .../wasm/terminal/selection_gesture_test.dart | 170 - .../test/wasm/terminal/selection_test.dart | 168 - .../test/wasm/terminal/terminal_test.dart | 1412 ----- packages/libghostty/tool/ffigen.dart | 7 +- 173 files changed, 19523 insertions(+), 18929 deletions(-) rename packages/libghostty/lib/src/{impl => api}/build_info.dart (87%) rename packages/libghostty/lib/src/{impl => api}/color.dart (84%) rename packages/libghostty/lib/src/{impl => api}/encode.dart (87%) rename packages/libghostty/lib/src/{impl => api}/key/key_encoder.dart (78%) rename packages/libghostty/lib/src/{impl => api}/key/key_event.dart (57%) rename packages/libghostty/lib/src/{impl => api}/key/kitty_key_flags.dart (85%) rename packages/libghostty/lib/src/{impl => api}/key/mods.dart (88%) rename packages/libghostty/lib/src/{impl => api}/mouse/mouse_encoder.dart (64%) rename packages/libghostty/lib/src/{impl => api}/mouse/mouse_event.dart (57%) rename packages/libghostty/lib/src/{impl => api}/osc_parser.dart (57%) rename packages/libghostty/lib/src/{impl => api}/paste.dart (88%) rename packages/libghostty/lib/src/{impl => api}/sgr_parser.dart (60%) rename packages/libghostty/lib/src/{impl => api}/sys.dart (58%) rename packages/libghostty/lib/src/{impl => api}/terminal/cell_iterator.dart (70%) rename packages/libghostty/lib/src/{impl => api}/terminal/formatter.dart (71%) rename packages/libghostty/lib/src/{impl => api}/terminal/grid_ref.dart (72%) create mode 100644 packages/libghostty/lib/src/api/terminal/kitty_graphics.dart rename packages/libghostty/lib/src/{impl => api}/terminal/render_state.dart (77%) rename packages/libghostty/lib/src/{impl => api}/terminal/row_iterator.dart (64%) rename packages/libghostty/lib/src/{impl => api}/terminal/selection.dart (83%) create mode 100644 packages/libghostty/lib/src/api/terminal/selection_gesture.dart rename packages/libghostty/lib/src/{impl => api}/terminal/terminal.dart (69%) rename packages/libghostty/lib/src/{impl => api}/terminal/terminal_mode.dart (96%) rename packages/libghostty/lib/src/{impl => api}/terminal/tracked_grid_ref.dart (69%) rename packages/libghostty/lib/src/{impl => api}/unicode.dart (94%) create mode 100644 packages/libghostty/lib/src/bindings/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/formatter/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/formatter/formatter.dart create mode 100644 packages/libghostty/lib/src/bindings/formatter/wasm.dart delete mode 100644 packages/libghostty/lib/src/bindings/interface.dart create mode 100644 packages/libghostty/lib/src/bindings/key/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/key/key.dart create mode 100644 packages/libghostty/lib/src/bindings/key/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/kitty_graphics/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/kitty_graphics/kitty_graphics.dart create mode 100644 packages/libghostty/lib/src/bindings/kitty_graphics/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/mouse/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/mouse/mouse.dart create mode 100644 packages/libghostty/lib/src/bindings/mouse/wasm.dart delete mode 100644 packages/libghostty/lib/src/bindings/native/native.dart create mode 100644 packages/libghostty/lib/src/bindings/parser/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/parser/parser.dart create mode 100644 packages/libghostty/lib/src/bindings/parser/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/render/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/render/render.dart create mode 100644 packages/libghostty/lib/src/bindings/render/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/result_helpers.dart create mode 100644 packages/libghostty/lib/src/bindings/selection/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/selection/selection.dart create mode 100644 packages/libghostty/lib/src/bindings/selection/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/system/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/system/sys.dart create mode 100644 packages/libghostty/lib/src/bindings/system/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/terminal/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/terminal/terminal.dart create mode 100644 packages/libghostty/lib/src/bindings/terminal/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/types.dart delete mode 100644 packages/libghostty/lib/src/bindings/types/aliases.dart delete mode 100644 packages/libghostty/lib/src/bindings/types/mouse_encoder_size.dart delete mode 100644 packages/libghostty/lib/src/bindings/types/position.dart delete mode 100644 packages/libghostty/lib/src/bindings/types/result.dart delete mode 100644 packages/libghostty/lib/src/bindings/types/types.dart create mode 100644 packages/libghostty/lib/src/bindings/utility/ffi.dart create mode 100644 packages/libghostty/lib/src/bindings/utility/utility.dart create mode 100644 packages/libghostty/lib/src/bindings/utility/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/wasm.dart create mode 100644 packages/libghostty/lib/src/bindings/wasm/allocator.dart create mode 100644 packages/libghostty/lib/src/bindings/wasm/bootstrap.dart create mode 100644 packages/libghostty/lib/src/bindings/wasm/bootstrap_stub.dart create mode 100644 packages/libghostty/lib/src/bindings/wasm/scratch.dart delete mode 100644 packages/libghostty/lib/src/bindings/wasm/wasm.dart rename packages/libghostty/lib/src/{ffi => generated}/libghostty.g.dart (99%) rename packages/libghostty/lib/src/{ffi => generated}/libghostty_enums.g.dart (100%) rename packages/libghostty/lib/src/{ffi => generated}/libghostty_wasm.g.dart (100%) delete mode 100644 packages/libghostty/lib/src/impl/terminal/cursor.dart delete mode 100644 packages/libghostty/lib/src/impl/terminal/kitty_graphics.dart delete mode 100644 packages/libghostty/lib/src/impl/terminal/selection_gesture.dart create mode 100644 packages/libghostty/lib/src/types/aliases.dart rename packages/libghostty/lib/src/{bindings => }/types/color.dart (79%) create mode 100644 packages/libghostty/lib/src/types/exceptions.dart rename packages/libghostty/lib/src/{bindings/types/formatter_extra.dart => types/formatter.dart} (69%) create mode 100644 packages/libghostty/lib/src/types/geometry.dart create mode 100644 packages/libghostty/lib/src/types/kitty_graphics.dart rename packages/libghostty/lib/src/{bindings/types/models.dart => types/render.dart} (52%) create mode 100644 packages/libghostty/lib/src/types/terminal.dart create mode 100644 packages/libghostty/lib/src/types/types.dart rename packages/libghostty/test/{impl => api}/build_info_test.dart (93%) rename packages/libghostty/test/{impl => api}/color_util_test.dart (64%) rename packages/libghostty/test/{impl => api}/encode_test.dart (96%) rename packages/libghostty/test/{impl => api}/formatter_test.dart (79%) rename packages/libghostty/test/{wasm => api/key}/key_encoder_test.dart (65%) rename packages/libghostty/test/{wasm => api/key}/key_event_test.dart (62%) rename packages/libghostty/test/{impl => api}/key/kitty_key_flags_test.dart (100%) rename packages/libghostty/test/{impl => api}/key/mods_test.dart (100%) rename packages/libghostty/test/{impl => api}/mouse/mouse_encoder_test.dart (68%) rename packages/libghostty/test/{impl => api}/mouse/mouse_event_test.dart (61%) rename packages/libghostty/test/{wasm/parsing => api}/osc_parser_test.dart (73%) rename packages/libghostty/test/{impl => api}/paste_test.dart (94%) rename packages/libghostty/test/{impl => api}/sgr_attribute_test.dart (100%) rename packages/libghostty/test/{wasm/parsing => api}/sgr_parser_test.dart (88%) rename packages/libghostty/test/{impl => api}/sys_test.dart (79%) rename packages/libghostty/test/{wasm/impl => api}/terminal/cell_iterator_test.dart (87%) rename packages/libghostty/test/{impl => api}/terminal/grid_ref_test.dart (89%) rename packages/libghostty/test/{impl => api}/terminal/helpers/cell_reader.dart (100%) rename packages/libghostty/test/{impl => api}/terminal/helpers/terminal_dump.dart (100%) rename packages/libghostty/test/{impl => api}/terminal/integration/terminal_integration_test.dart (98%) rename packages/libghostty/test/{impl => api}/terminal/kitty_graphics_test.dart (97%) create mode 100644 packages/libghostty/test/api/terminal/kitty_temp_file_directory_ffi_test.dart create mode 100644 packages/libghostty/test/api/terminal/kitty_temp_file_directory_wasm_test.dart create mode 100644 packages/libghostty/test/api/terminal/render_state_test.dart rename packages/libghostty/test/{wasm/impl => api}/terminal/row_iterator_test.dart (71%) create mode 100644 packages/libghostty/test/api/terminal/selection_gesture_test.dart rename packages/libghostty/test/{impl => api}/terminal/selection_test.dart (98%) rename packages/libghostty/test/{impl => api}/terminal/terminal_colors_test.dart (97%) rename packages/libghostty/test/{impl => api}/terminal/terminal_mode_test.dart (91%) rename packages/libghostty/test/{impl => api}/terminal/terminal_scrollbar_test.dart (91%) rename packages/libghostty/test/{impl => api}/terminal/terminal_test.dart (82%) rename packages/libghostty/test/{wasm/impl => api}/terminal/tracked_grid_ref_test.dart (82%) rename packages/libghostty/test/{impl => api}/unicode_test.dart (91%) delete mode 100644 packages/libghostty/test/bindings/bindings_native_test.dart create mode 100644 packages/libghostty/test/bindings/formatter/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/key/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/kitty_graphics/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/mouse/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/parser/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/render/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/result_helpers_test.dart create mode 100644 packages/libghostty/test/bindings/selection/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/system/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/terminal/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/utility/ffi_test.dart create mode 100644 packages/libghostty/test/bindings/wasm/bootstrap_test.dart create mode 100644 packages/libghostty/test/bindings/wasm/memory_test.dart create mode 100644 packages/libghostty/test/bindings/wasm/scratch_test.dart rename packages/libghostty/test/{wasm => }/helpers/asset_server.dart (100%) create mode 100644 packages/libghostty/test/helpers/setup.dart create mode 100644 packages/libghostty/test/helpers/setup_vm.dart rename packages/libghostty/test/{wasm/helpers/setup.dart => helpers/setup_wasm.dart} (71%) delete mode 100644 packages/libghostty/test/impl/key/key_encoder_test.dart delete mode 100644 packages/libghostty/test/impl/key/key_event_test.dart delete mode 100644 packages/libghostty/test/impl/osc_parser_test.dart delete mode 100644 packages/libghostty/test/impl/sgr_parser_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/cell_iterator_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/cell_style_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/cursor_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/position_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/row_iterator_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/selection_gesture_test.dart delete mode 100644 packages/libghostty/test/impl/terminal/tracked_grid_ref_test.dart create mode 100644 packages/libghostty/test/types/formatter_test.dart create mode 100644 packages/libghostty/test/types/geometry_test.dart create mode 100644 packages/libghostty/test/types/kitty_graphics_test.dart create mode 100644 packages/libghostty/test/types/render_test.dart create mode 100644 packages/libghostty/test/types/terminal_test.dart delete mode 100644 packages/libghostty/test/wasm/bindings_wasm_test.dart delete mode 100644 packages/libghostty/test/wasm/impl/terminal/grid_ref_test.dart delete mode 100644 packages/libghostty/test/wasm/paste_test.dart delete mode 100644 packages/libghostty/test/wasm/terminal/selection_gesture_test.dart delete mode 100644 packages/libghostty/test/wasm/terminal/selection_test.dart delete mode 100644 packages/libghostty/test/wasm/terminal/terminal_test.dart diff --git a/packages/flterm/lib/src/controller/kitty_png_decoder.dart b/packages/flterm/lib/src/controller/kitty_png_decoder.dart index 15169467..a2fc3f1f 100644 --- a/packages/flterm/lib/src/controller/kitty_png_decoder.dart +++ b/packages/flterm/lib/src/controller/kitty_png_decoder.dart @@ -18,5 +18,9 @@ DecodedImage? _decodePng(Uint8List bytes) { final decoded = decodePng(bytes); if (decoded == null) return null; final rgba = decoded.convert(format: .uint8, numChannels: 4); - return (width: rgba.width, height: rgba.height, rgba: rgba.toUint8List()); + return DecodedImage( + width: rgba.width, + height: rgba.height, + rgba: rgba.toUint8List(), + ); } diff --git a/packages/flterm/lib/src/controller/terminal_controller_impl.dart b/packages/flterm/lib/src/controller/terminal_controller_impl.dart index 0ad8fe18..d7be7b7b 100644 --- a/packages/flterm/lib/src/controller/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/controller/terminal_controller_impl.dart @@ -579,7 +579,7 @@ final class TerminalControllerImpl extends TerminalController { _terminal.kittyImageStorageLimit = _config.kittyImageStorageLimit; _terminal.setApcBufferLimit(_config.apcBufferLimit); _terminal.setGlyphProtocol(enabled: _config.glyphProtocol); - _terminal.defaultCursorShape = _config.cursorStyle; + _terminal.defaultCursorShape = .fromValue(_config.cursorStyle.value); _terminal.defaultCursorBlink = _config.cursorBlink; } diff --git a/packages/flterm/lib/src/interaction/selection_gesture_driver.dart b/packages/flterm/lib/src/interaction/selection_gesture_driver.dart index bebf76af..c97f110b 100644 --- a/packages/flterm/lib/src/interaction/selection_gesture_driver.dart +++ b/packages/flterm/lib/src/interaction/selection_gesture_driver.dart @@ -104,10 +104,6 @@ final class SelectionGestureDriver { void _setWordBoundaryCodepoints(SelectionGestureEvent event) { final codepoints = _wordBoundaryCodepoints; - if (codepoints == null) { - event.clear(.wordBoundaryCodepoints); - return; - } event.setWordBoundaryCodepoints(codepoints); } } diff --git a/packages/flterm/test/controller/terminal_controller_test.dart b/packages/flterm/test/controller/terminal_controller_test.dart index 19b6d150..d1b997b1 100644 --- a/packages/flterm/test/controller/terminal_controller_test.dart +++ b/packages/flterm/test/controller/terminal_controller_test.dart @@ -193,12 +193,15 @@ void main() { ), ); - expect(controller.terminal.geometry, ( - cols: 80, - rows: 24, - widthPx: 1280, - heightPx: 768, - )); + expect( + controller.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 1280, + heightPx: 768, + ), + ); }); test('updates physical geometry when the grid is unchanged', () { @@ -230,12 +233,15 @@ void main() { ), ); - expect(binding.terminal.geometry, ( - cols: 80, - rows: 24, - widthPx: 800, - heightPx: 480, - )); + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 800, + heightPx: 480, + ), + ); }); test('ignores resize events with invalid physical geometry', () { @@ -268,12 +274,15 @@ void main() { ), ); - expect(binding.terminal.geometry, ( - cols: 80, - rows: 24, - widthPx: 640, - heightPx: 384, - )); + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + ), + ); }); test('ignores resize events beyond the native grid limit', () { @@ -307,12 +316,15 @@ void main() { ), ); - expect(binding.terminal.geometry, ( - cols: 80, - rows: 24, - widthPx: 640, - heightPx: 384, - )); + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + ), + ); }); test('emits the measured in-band resize report', () { @@ -843,7 +855,10 @@ void main() { writeControllerUtf8(controller, '\x1b]9;Build finished\x07'); - expect(notification, (title: '', body: 'Build finished')); + expect( + notification, + const DesktopNotification(title: '', body: 'Build finished'), + ); }); }); @@ -854,7 +869,7 @@ void main() { writeControllerUtf8(controller, '\x1b]9;4;1;42\x07'); - expect(report, (state: TerminalProgressState.set, progress: 42)); + expect(report, const TerminalProgress(state: .set, progress: 42)); }); }); diff --git a/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart b/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart index 78ef182b..f82368d7 100644 --- a/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart +++ b/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart @@ -24,7 +24,7 @@ void main() { final decoded = img.decodePng(bytes); if (decoded == null) return null; final rgba = decoded.convert(format: img.Format.uint8, numChannels: 4); - return ( + return DecodedImage( width: rgba.width, height: rgba.height, rgba: Uint8List.fromList(rgba.toUint8List()), diff --git a/packages/flterm/test/rendering/terminal_renderer_test.dart b/packages/flterm/test/rendering/terminal_renderer_test.dart index a91f4a41..3482e53e 100644 --- a/packages/flterm/test/rendering/terminal_renderer_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_test.dart @@ -229,12 +229,15 @@ void main() { await tester.pumpWidget(wrap(terminal)); await tester.pumpWidget(wrap(replacement)); - expect(replacement.geometry, ( - cols: defaultCols, - rows: defaultRows, - widthPx: defaultCols * defaultMetrics.cellWidth.toInt(), - heightPx: defaultRows * defaultMetrics.cellHeight.toInt(), - )); + expect( + replacement.geometry, + const TerminalGeometry( + cols: defaultCols, + rows: defaultRows, + widthPx: defaultCols * 8, + heightPx: defaultRows * 16, + ), + ); }); testWidgets('theme change triggers layout', (tester) async { diff --git a/packages/libghostty/lib/libghostty.dart b/packages/libghostty/lib/libghostty.dart index a1b699b9..3a9be768 100644 --- a/packages/libghostty/lib/libghostty.dart +++ b/packages/libghostty/lib/libghostty.dart @@ -1,86 +1,21 @@ -/// Terminal emulation powered by libghostty. +/// Framework-independent terminal emulation powered by libghostty. +/// +/// The package exposes idiomatic Dart values and resource types over the +/// libghostty C ABI. Native applications load libghostty through package build +/// hooks. Web applications must call [initializeForWeb] with a compatible Wasm +/// artifact before constructing resources. +/// +/// Dispose owned resources such as [Terminal], [RenderState], and encoders when +/// they are no longer needed. Borrowed views, including [GridRef] and +/// [KittyImage], must be reacquired after a terminal mutation invalidates them. /// /// ```dart /// import 'package:libghostty/libghostty.dart'; /// ``` library; -export 'src/bindings/bindings.dart' show initializeForWeb; -export 'src/bindings/types/aliases.dart' - show - ClipboardContent, - ClipboardWrite, - ClipboardWriteCallback, - DecodedImage, - DesktopNotification, - DesktopNotificationCallback, - PngDecoder, - TerminalGeometry, - TerminalProgress, - TerminalProgressCallback, - X11ColorName; -export 'src/bindings/types/types.dart' - show - CellColor, - CellWidth, - CursorShape, - DefaultColor, - DeviceAttributesPrimary, - DeviceAttributesResponse, - DeviceAttributesSecondary, - DeviceAttributesTertiary, - FormatterExtra, - InvalidValueException, - LibGhosttyException, - MouseEncoderSize, - MouseFormat, - MouseTracking, - NamedColor, - OptimizeMode, - OutOfMemoryException, - PaletteColor, - Position, - RgbColor, - Scrollbar, - SemanticContent, - SemanticPrompt, - SgrAttribute, - Style, - TerminalColors, - TerminalSizeInfo, - UnderlineStyle; -export 'src/ffi/libghostty_enums.g.dart' - show - ClipboardLocation, - ClipboardWriteResult, - ColorScheme, - FocusEvent, - FormatterFormat, - Key, - KeyAction, - KittyImageCompression, - KittyImageFormat, - KittyPlacementLayer, - ModeReportState, - MouseAction, - MouseButton, - OptionAsAlt, - OscCommandType, - PointTag, - SelectionAdjust, - SelectionGestureAutoscroll, - SelectionGestureBehavior, - SelectionGestureEventOption, - SelectionOrder, - SgrAttributeTag, - SizeReportStyle, - SysLogLevel, - TerminalCompressionMode, - TerminalCompressionResult, - TerminalProgressState, - TerminalScreen; -export 'src/impl/build_info.dart' show LibGhosttyBuildInfo; -export 'src/impl/color.dart' +export 'src/api/build_info.dart' show LibGhosttyBuildInfo; +export 'src/api/color.dart' show colorContrast, colorLuminance, @@ -91,18 +26,17 @@ export 'src/impl/color.dart' parsePaletteEntry, parseX11ColorName, x11ColorNames; -export 'src/impl/encode.dart' +export 'src/api/encode.dart' show ColorSchemeReportEncode, FocusEventEncode, SizeReportStyleEncode; -export 'src/impl/key/kitty_key_flags.dart' show KittyKeyFlags; -export 'src/impl/key/mods.dart' show Mods; -export 'src/impl/osc_parser.dart' show OscCommand, OscParser; -export 'src/impl/paste.dart' show pasteEncode, pasteIsSafe; -export 'src/impl/sgr_parser.dart' show SgrParser; -export 'src/impl/sys.dart' show LibGhostty, LogCallback; -export 'src/impl/terminal/terminal.dart' +export 'src/api/key/kitty_key_flags.dart' show KittyKeyFlags; +export 'src/api/key/mods.dart' show Mods; +export 'src/api/osc_parser.dart' show OscCommand, OscParser; +export 'src/api/paste.dart' show pasteEncode, pasteIsSafe; +export 'src/api/sgr_parser.dart' show SgrParser; +export 'src/api/sys.dart' show LibGhostty, LogCallback; +export 'src/api/terminal/terminal.dart' show CellIterator, - Cursor, DirtyState, Formatter, GridRef, @@ -112,8 +46,6 @@ export 'src/impl/terminal/terminal.dart' KittyImage, MouseEncoder, MouseEvent, - Placement, - RenderInfo, RenderState, RowIterator, RowSelectionRange, @@ -125,6 +57,93 @@ export 'src/impl/terminal/terminal.dart' SelectionGestureState, Terminal, TrackedGridRef; -export 'src/impl/terminal/terminal_mode.dart' show TerminalMode; -export 'src/impl/unicode.dart' show unicodeCodepointWidth, unicodeGraphemeWidth; +export 'src/api/terminal/terminal_mode.dart' show TerminalMode; +export 'src/api/unicode.dart' show unicodeCodepointWidth, unicodeGraphemeWidth; +export 'src/bindings/bindings.dart' show initializeForWeb; +export 'src/generated/libghostty_enums.g.dart' + show + ClipboardLocation, + ClipboardWriteResult, + ColorScheme, + FocusEvent, + FormatterFormat, + Key, + KeyAction, + KittyImageCompression, + KittyImageFormat, + KittyPlacementLayer, + ModeReportState, + MouseAction, + MouseButton, + OptionAsAlt, + OscCommandType, + PointTag, + SelectionAdjust, + SelectionGestureAutoscroll, + SelectionGestureBehavior, + SelectionOrder, + SgrAttributeTag, + SizeReportStyle, + SysLogLevel, + TerminalCompressionMode, + TerminalCompressionResult, + TerminalProgressState, + TerminalScreen; export 'src/listenable.dart' show Listenable; +export 'src/types/aliases.dart' + show + ClipboardWriteCallback, + ContinuationWriter, + DesktopNotificationCallback, + PngDecoder, + SysLogCallback, + TerminalCursorShape, + TerminalProgressCallback, + ValueGetter, + ValueSetter, + VoidCallback; +export 'src/types/types.dart' + show + CellColor, + CellWidth, + ClipboardContent, + ClipboardWrite, + Cursor, + CursorShape, + DecodedImage, + DefaultColor, + DesktopNotification, + DeviceAttributesPrimary, + DeviceAttributesResponse, + DeviceAttributesSecondary, + DeviceAttributesTertiary, + FormatterExtra, + InvalidValueException, + IoException, + KittyPlacement, + KittyPlacementRenderInfo, + LibGhosttyException, + LimitExceededException, + MouseEncoderSize, + MouseFormat, + MouseTracking, + NamedColor, + NoValueException, + OptimizeMode, + OutOfMemoryException, + OutOfSpaceException, + PaletteColor, + Position, + RgbColor, + Scrollbar, + SemanticContent, + SemanticPrompt, + SgrAttribute, + Style, + TerminalColors, + TerminalGeometry, + TerminalProgress, + TerminalSizeInfo, + UnderlineStyle, + UnknownResultException, + X11ColorName; diff --git a/packages/libghostty/lib/src/impl/build_info.dart b/packages/libghostty/lib/src/api/build_info.dart similarity index 87% rename from packages/libghostty/lib/src/impl/build_info.dart rename to packages/libghostty/lib/src/api/build_info.dart index 87f9449e..1659af58 100644 --- a/packages/libghostty/lib/src/impl/build_info.dart +++ b/packages/libghostty/lib/src/api/build_info.dart @@ -1,5 +1,5 @@ import '../bindings/bindings.dart'; -import '../ffi/libghostty_enums.g.dart'; +import '../generated/libghostty_enums.g.dart'; /// Compile-time build configuration of the native libghostty library. /// @@ -12,7 +12,7 @@ import '../ffi/libghostty_enums.g.dart'; /// print('${info.versionString} (${info.optimizeMode})'); /// print('SIMD: ${info.simd}, Kitty graphics: ${info.kittyGraphics}'); /// ``` -class LibGhosttyBuildInfo { +final class LibGhosttyBuildInfo { /// Singleton instance. All values are populated once on first access. static final instance = LibGhosttyBuildInfo._(); @@ -57,11 +57,11 @@ class LibGhosttyBuildInfo { versionPatch = _getInt(.versionPatch), versionBuild = _getString(.versionBuild); - static bool _getBool(BuildInfo data) => check(bindings.buildInfoBool(data)); + static bool _getBool(BuildInfo data) => bindings.utility.buildInfoBool(data); - static int _getInt(BuildInfo data) => check(bindings.buildInfo(data)); + static int _getInt(BuildInfo data) => bindings.utility.buildInfo(data); static String _getString(BuildInfo data) { - return check(bindings.buildInfoString(data)); + return bindings.utility.buildInfoString(data); } } diff --git a/packages/libghostty/lib/src/impl/color.dart b/packages/libghostty/lib/src/api/color.dart similarity index 84% rename from packages/libghostty/lib/src/impl/color.dart rename to packages/libghostty/lib/src/api/color.dart index 04b679ed..586eab26 100644 --- a/packages/libghostty/lib/src/impl/color.dart +++ b/packages/libghostty/lib/src/api/color.dart @@ -1,16 +1,18 @@ import '../bindings/bindings.dart'; +import '../types/types.dart'; /// Calculates the WCAG contrast ratio between [a] and [b]. /// /// The value is symmetric and ranges from 1.0 for identical colors to 21.0 /// for black on white. -double colorContrast(RgbColor a, RgbColor b) => bindings.colorContrast(a, b); +double colorContrast(RgbColor a, RgbColor b) => + bindings.utility.colorContrast(a, b); /// Calculates W3C relative luminance for [color]. /// /// The value ranges from 0.0 for black to 1.0 for white, using the WCAG /// relative luminance definition. -double colorLuminance(RgbColor color) => bindings.colorLuminance(color); +double colorLuminance(RgbColor color) => bindings.utility.colorLuminance(color); /// Calculates perceived luminance for [color]. /// @@ -19,14 +21,14 @@ double colorLuminance(RgbColor color) => bindings.colorLuminance(color); /// WCAG relative luminance metric and not the CIELAB lightness used by /// [generateColorPalette]. double colorPerceivedLuminance(RgbColor color) { - return bindings.colorPerceivedLuminance(color); + return bindings.utility.colorPerceivedLuminance(color); } /// Returns the built-in default 256-color palette. /// /// The palette contains the default 16 ANSI colors, the xterm 6x6x6 color /// cube, and the grayscale ramp. -List defaultColorPalette() => bindings.colorPaletteDefault(); +List defaultColorPalette() => bindings.utility.colorPaletteDefault(); /// Generates a 256-color palette using terminal palette interpolation. /// @@ -55,7 +57,7 @@ List generateColorPalette({ for (final index in skip) { RangeError.checkValueInInterval(index, 0, 255, 'skip'); } - return bindings.colorPaletteGenerate( + return bindings.utility.colorPaletteGenerate( base: base, skip: skip, background: background, @@ -73,7 +75,7 @@ List generateColorPalette({ /// /// Leading and trailing spaces and tabs are ignored. Throws /// [InvalidValueException] when [value] is not a valid color. -RgbColor parseColor(String value) => check(bindings.colorParse(value)); +RgbColor parseColor(String value) => bindings.utility.colorParse(value); /// Parses a palette override in `INDEX=COLOR` form. /// @@ -84,7 +86,7 @@ RgbColor parseColor(String value) => check(bindings.colorParse(value)); /// Throws [InvalidValueException] when [value] is not a valid palette entry, /// including index overflow. ({int index, RgbColor color}) parsePaletteEntry(String value) { - return check(bindings.colorParsePaletteEntry(value)); + return bindings.utility.colorParsePaletteEntry(value); } /// Parses an X11 color name using the embedded `rgb.txt` table. @@ -93,11 +95,11 @@ RgbColor parseColor(String value) => check(bindings.colorParse(value)); /// case-insensitive. Hex values and `rgb:`/`rgbi:` values are not accepted. /// /// Throws [InvalidValueException] when [name] is not a known X11 color name. -RgbColor parseX11ColorName(String name) => check(bindings.colorParseX11(name)); +RgbColor parseX11ColorName(String name) => bindings.utility.colorParseX11(name); /// X11 color names recognized by the color parser. /// /// Entries are returned in `rgb.txt` order. Aliases are separate entries, such /// as `medium spring green` and `MediumSpringGreen`; [parseX11ColorName] /// matches supported spellings case-insensitively. -List x11ColorNames() => bindings.colorX11Names(); +List x11ColorNames() => bindings.utility.colorX11Names(); diff --git a/packages/libghostty/lib/src/impl/encode.dart b/packages/libghostty/lib/src/api/encode.dart similarity index 87% rename from packages/libghostty/lib/src/impl/encode.dart rename to packages/libghostty/lib/src/api/encode.dart index dd4b7a9f..bdaa16a2 100644 --- a/packages/libghostty/lib/src/impl/encode.dart +++ b/packages/libghostty/lib/src/api/encode.dart @@ -1,5 +1,16 @@ import '../bindings/bindings.dart'; -import '../ffi/libghostty_enums.g.dart'; +import '../generated/libghostty_enums.g.dart'; + +/// Adds [encode] to [ColorScheme] for encoding color-scheme reports. +extension ColorSchemeReportEncode on ColorScheme { + /// Encodes this color scheme as the terminal escape sequence used by + /// color-scheme reporting mode. + /// + /// [ColorScheme.dark] emits `ESC [ ? 997 ; 1 n`; [ColorScheme.light] emits + /// `ESC [ ? 997 ; 2 n`. Hosts should only send unsolicited reports when + /// color-scheme reporting mode 2031 is enabled. + String encode() => bindings.utility.colorSchemeReportEncode(this); +} /// Adds [encode] to [FocusEvent] for encoding focus gained/lost events into /// terminal escape sequences (CSI I / CSI O) for focus reporting mode @@ -15,18 +26,7 @@ extension FocusEventEncode on FocusEvent { /// ```dart /// final seq = FocusEvent.gained.encode(); /// ``` - String encode() => check(bindings.focusEncode(this)); -} - -/// Adds [encode] to [ColorScheme] for encoding color-scheme reports. -extension ColorSchemeReportEncode on ColorScheme { - /// Encodes this color scheme as the terminal escape sequence used by - /// color-scheme reporting mode. - /// - /// [ColorScheme.dark] emits `ESC [ ? 997 ; 1 n`; [ColorScheme.light] emits - /// `ESC [ ? 997 ; 2 n`. Hosts should only send unsolicited reports when - /// color-scheme reporting mode 2031 is enabled. - String encode() => check(bindings.colorSchemeReportEncode(this)); + String encode() => bindings.utility.focusEncode(this); } /// Adds [encode] to [SizeReportStyle] for encoding terminal size reports @@ -53,7 +53,11 @@ extension SizeReportStyleEncode on SizeReportStyle { required int columns, required int cellWidth, required int cellHeight, - }) => check( - bindings.sizeReportEncode(this, rows, columns, cellWidth, cellHeight), + }) => bindings.utility.sizeReportEncode( + this, + rows, + columns, + cellWidth, + cellHeight, ); } diff --git a/packages/libghostty/lib/src/impl/key/key_encoder.dart b/packages/libghostty/lib/src/api/key/key_encoder.dart similarity index 78% rename from packages/libghostty/lib/src/impl/key/key_encoder.dart rename to packages/libghostty/lib/src/api/key/key_encoder.dart index b94d34ae..ca4ef3f3 100644 --- a/packages/libghostty/lib/src/impl/key/key_encoder.dart +++ b/packages/libghostty/lib/src/api/key/key_encoder.dart @@ -7,9 +7,8 @@ part of '../terminal/terminal.dart'; /// before encoding. Options can be set individually ([setCursorKeyApplication], /// [setKittyFlags], etc.) or synced in bulk from a [Terminal] via [sync]. /// -/// When used with a [Terminal], call [sync] immediately before each -/// [encode] so the produced sequence matches the terminal's current -/// mode state. +/// When used with a [Terminal], call [sync] immediately before each [encode] +/// so the produced sequence matches the terminal's current mode state. /// /// ```dart /// final terminal = Terminal(cols: 80, rows: 24); @@ -27,11 +26,11 @@ part of '../terminal/terminal.dart'; /// encoder.dispose(); /// terminal.dispose(); /// ``` -@immutable final class KeyEncoder { - static final _finalizer = Finalizer(bindings.keyEncoderFree); + static final _finalizer = Finalizer(bindings.key.keyEncoderFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new key encoder with default options. /// @@ -39,17 +38,19 @@ final class KeyEncoder { /// methods or [sync] before calling [encode]. /// /// Throws [OutOfMemoryException] if the native allocation fails. - KeyEncoder() : _handle = check(bindings.keyEncoderNew()) { + KeyEncoder() : _handle = bindings.key.keyEncoderNew() { _finalizer.attach(this, _handle, detach: this); } /// Releases the native encoder handle. /// - /// Must be called to free resources; the encoder must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. void dispose() { + if (_disposed) return; + bindings.key.keyEncoderFree(_handle); _finalizer.detach(this); - bindings.keyEncoderFree(_handle); + _disposed = true; } /// Encodes a [KeyEvent] into the appropriate terminal escape sequence based @@ -70,7 +71,9 @@ final class KeyEncoder { /// if (seq.isNotEmpty) pty.write(utf8.encode(seq)); /// ``` String encode(KeyEvent event) { - return check(bindings.keyEncoderEncode(_handle, event._handle)); + final handle = _requireHandle(); + final eventHandle = event._requireHandle(); + return bindings.key.keyEncoderEncode(handle, eventHandle); } /// Sets DEC mode 1036: whether Alt sends an ESC prefix before the key @@ -79,6 +82,14 @@ final class KeyEncoder { _setOptBool(.altEscPrefix, enabled); } + /// Sets DEC mode 67: back-arrow key mode. + /// + /// When enabled, Backspace emits BS (`0x08`) in legacy key encoding. + /// When disabled, Backspace emits DEL (`0x7f`). + void setBackArrowKeyMode({required bool enabled}) { + _setOptBool(.backarrowKeyMode, enabled); + } + /// Sets DEC mode 1: cursor key application mode. /// /// When enabled, cursor keys send SS3-prefixed sequences (e.g. `\eOA`) @@ -100,21 +111,14 @@ final class KeyEncoder { _setOptBool(.keypadKeyApplication, enabled); } - /// Sets DEC mode 67: back-arrow key mode. - /// - /// When enabled, Backspace emits BS (`0x08`) in legacy key encoding. - /// When disabled, Backspace emits DEL (`0x7f`). - void setBackArrowKeyMode({required bool enabled}) { - _setOptBool(.backarrowKeyMode, enabled); - } - /// Sets the Kitty keyboard protocol flags controlling which key events and /// metadata are reported. /// - /// Pass [KittyKeyFlags.disabled] to use legacy encoding. Flags can be + /// Pass `KittyKeyFlags.disabled` to use legacy encoding. Flags can be /// combined with `|` to enable multiple modes simultaneously. void setKittyFlags(KittyKeyFlags flags) { - bindings.keyEncoderSetKittyFlags(_handle, flags.value); + final handle = _requireHandle(); + bindings.key.keyEncoderSetKittyFlags(handle, flags.value); } /// Sets xterm modifyOtherKeys mode 2. @@ -129,10 +133,11 @@ final class KeyEncoder { /// /// Controls whether the macOS Option key is treated as Alt for encoding /// purposes. This option cannot be determined from terminal state, so - /// [sync] resets it to [OptionAsAlt.false$]. Call this method afterward - /// if needed. + /// [sync] resets it to [OptionAsAlt.false$]. Call this method afterward if + /// needed. void setOptionAsAlt(OptionAsAlt option) { - bindings.keyEncoderSetOptionAsAlt(_handle, option); + final handle = _requireHandle(); + bindings.key.keyEncoderSetOptionAsAlt(handle, option); } /// Syncs all encoder options from [terminal]'s current mode state. @@ -142,17 +147,25 @@ final class KeyEncoder { /// mode (DEC 67), alt escape prefix (DEC 1036), modifyOtherKeys state, and /// Kitty keyboard protocol flags. /// - /// Call immediately before each [encode] so the produced sequence - /// matches the terminal's current mode state. + /// Call immediately before each [encode] so the produced sequence matches + /// the terminal's current mode state. /// /// The macOS option-as-alt option cannot be determined from terminal state /// and is reset to [OptionAsAlt.false$] by this call. Use [setOptionAsAlt] /// afterward if needed. void sync(Terminal terminal) { - bindings.keyEncoderSetOptFromTerminal(_handle, terminal._handle); + final handle = _requireHandle(); + final terminalHandle = terminal._terminalHandle; + bindings.key.keyEncoderSetOptFromTerminal(handle, terminalHandle); + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) throw StateError('KeyEncoder has been disposed'); + return _handle; } void _setOptBool(KeyEncoderOption option, bool enabled) { - bindings.keyEncoderSetBoolOpt(_handle, option, value: enabled); + final handle = _requireHandle(); + bindings.key.keyEncoderSetBoolOpt(handle, option, value: enabled); } } diff --git a/packages/libghostty/lib/src/impl/key/key_event.dart b/packages/libghostty/lib/src/api/key/key_event.dart similarity index 57% rename from packages/libghostty/lib/src/impl/key/key_event.dart rename to packages/libghostty/lib/src/api/key/key_event.dart index 88534e9c..ba6601b9 100644 --- a/packages/libghostty/lib/src/impl/key/key_event.dart +++ b/packages/libghostty/lib/src/api/key/key_event.dart @@ -21,11 +21,11 @@ part of '../terminal/terminal.dart'; /// /// event.dispose(); /// ``` -@immutable final class KeyEvent { - static final _finalizer = Finalizer(bindings.keyEventFree); + static final _finalizer = Finalizer(bindings.key.keyEventFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new key event with default values. /// @@ -34,24 +34,34 @@ final class KeyEvent { /// between encode calls. /// /// Throws [OutOfMemoryException] if the native allocation fails. - KeyEvent() : _handle = check(bindings.keyEventNew()) { + KeyEvent() : _handle = bindings.key.keyEventNew() { _finalizer.attach(this, _handle, detach: this); } /// The key action: [KeyAction.press], [KeyAction.release], or /// [KeyAction.repeat]. - KeyAction get action => bindings.keyEventGetAction(_handle); + KeyAction get action { + final handle = _requireHandle(); + return bindings.key.keyEventGetAction(handle); + } /// Sets the key action (press, release, repeat). - set action(KeyAction value) => bindings.keyEventSetAction(_handle, value); + set action(KeyAction value) { + final handle = _requireHandle(); + bindings.key.keyEventSetAction(handle, value); + } /// Whether this event is part of a composition sequence (e.g. dead keys, /// IME input). - bool get composing => bindings.keyEventGetComposing(_handle); + bool get composing { + final handle = _requireHandle(); + return bindings.key.keyEventGetComposing(handle); + } /// Sets whether this event is part of a composition sequence. set composing(bool value) { - bindings.keyEventSetComposing(_handle, composing: value); + final handle = _requireHandle(); + bindings.key.keyEventSetComposing(handle, composing: value); } /// Modifiers consumed by the platform during key processing. @@ -60,12 +70,14 @@ final class KeyEvent { /// encoder uses this to avoid double-reporting modifiers that the platform /// already handled. Mods get consumedMods { - return Mods.fromValue(bindings.keyEventGetConsumedMods(_handle)); + final handle = _requireHandle(); + return Mods.fromValue(bindings.key.keyEventGetConsumedMods(handle)); } /// Sets the consumed modifiers bitmask. set consumedMods(Mods value) { - bindings.keyEventSetConsumedMods(_handle, value.value); + final handle = _requireHandle(); + bindings.key.keyEventSetConsumedMods(handle, value.value); } /// Physical, layout-independent key code based on the W3C UI Events @@ -73,32 +85,51 @@ final class KeyEvent { /// /// Represents the physical key position, not the character it produces. For /// example, [Key.a] on a US keyboard and the equivalent position on a - /// Russian keyboard both report the same key code. Layout-dependent text - /// is provided separately via [utf8]. - Key get key => bindings.keyEventGetKey(_handle); + /// Russian keyboard both report the same key code. Layout-dependent text is + /// provided separately via [utf8]. + Key get key { + final handle = _requireHandle(); + return bindings.key.keyEventGetKey(handle); + } /// Sets the physical key code. - set key(Key value) => bindings.keyEventSetKey(_handle, value); + set key(Key value) { + final handle = _requireHandle(); + bindings.key.keyEventSetKey(handle, value); + } /// Active modifier keys at the time of the event. - Mods get mods => Mods.fromValue(bindings.keyEventGetMods(_handle)); + Mods get mods { + final handle = _requireHandle(); + return Mods.fromValue(bindings.key.keyEventGetMods(handle)); + } /// Sets the modifier keys bitmask. - set mods(Mods value) => bindings.keyEventSetMods(_handle, value.value); + set mods(Mods value) { + final handle = _requireHandle(); + bindings.key.keyEventSetMods(handle, value.value); + } /// Unicode codepoint for this key without Shift applied. - int get unshiftedCodepoint => bindings.keyEventGetUnshiftedCodepoint(_handle); + int get unshiftedCodepoint { + final handle = _requireHandle(); + return bindings.key.keyEventGetUnshiftedCodepoint(handle); + } /// Sets the unshifted Unicode codepoint. set unshiftedCodepoint(int value) { - bindings.keyEventSetUnshiftedCodepoint(_handle, value); + final handle = _requireHandle(); + bindings.key.keyEventSetUnshiftedCodepoint(handle, value); } /// UTF-8 text generated by the keyboard layout for this key. /// - /// Returns null when no text is set. The returned value is valid until the - /// event is disposed or the text is modified via the setter. - String? get utf8 => bindings.keyEventGetUtf8(_handle); + /// Returns null when no text is set. The returned string is a Dart-owned + /// snapshot and remains valid after the event is modified or disposed. + String? get utf8 { + final handle = _requireHandle(); + return bindings.key.keyEventGetUtf8(handle); + } /// Sets the UTF-8 text generated by the keyboard layout for this key, or /// null to clear it. @@ -112,14 +143,24 @@ final class KeyEvent { /// let the encoder use the logical key. /// /// The string is copied into the event. - set utf8(String? value) => bindings.keyEventSetUtf8(_handle, value); + set utf8(String? value) { + final handle = _requireHandle(); + bindings.key.keyEventSetUtf8(handle, value); + } /// Releases the native key event handle. /// - /// Must be called to free resources; the event must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. void dispose() { + if (_disposed) return; + bindings.key.keyEventFree(_handle); _finalizer.detach(this); - bindings.keyEventFree(_handle); + _disposed = true; + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) throw StateError('KeyEvent has been disposed'); + return _handle; } } diff --git a/packages/libghostty/lib/src/impl/key/kitty_key_flags.dart b/packages/libghostty/lib/src/api/key/kitty_key_flags.dart similarity index 85% rename from packages/libghostty/lib/src/impl/key/kitty_key_flags.dart rename to packages/libghostty/lib/src/api/key/kitty_key_flags.dart index 844d032e..6c6bb0df 100644 --- a/packages/libghostty/lib/src/impl/key/kitty_key_flags.dart +++ b/packages/libghostty/lib/src/api/key/kitty_key_flags.dart @@ -7,8 +7,9 @@ import 'package:meta/meta.dart'; /// sequences; the terminal exposes the current value through /// [Terminal.kittyKeyboardFlags]. /// -/// Combine flags using the `|` operator. Use [disabled] for legacy encoding -/// (all flags off), or [all] to enable every mode. +/// Combine flags using the `|` operator. Use `KittyKeyFlags.disabled` for +/// legacy encoding (all flags off), or `KittyKeyFlags.all` to enable every +/// mode. /// /// ```dart /// final flags = KittyKeyFlags.disambiguate() | KittyKeyFlags.reportEvents(); @@ -45,9 +46,11 @@ extension type const KittyKeyFlags._(int value) { /// Whether all flags are off (legacy encoding). bool get isDisabled => value == 0; + /// Returns the flags present in both this value and [other]. KittyKeyFlags operator &(KittyKeyFlags other) => .fromValue(value & other.value); + /// Returns the union of this value and [other]. Unknown bits are preserved. KittyKeyFlags operator |(KittyKeyFlags other) => .fromValue(value | other.value); } diff --git a/packages/libghostty/lib/src/impl/key/mods.dart b/packages/libghostty/lib/src/api/key/mods.dart similarity index 88% rename from packages/libghostty/lib/src/impl/key/mods.dart rename to packages/libghostty/lib/src/api/key/mods.dart index e1b5c66c..19fc8d04 100644 --- a/packages/libghostty/lib/src/impl/key/mods.dart +++ b/packages/libghostty/lib/src/api/key/mods.dart @@ -3,7 +3,8 @@ import 'package:meta/meta.dart'; /// Keyboard modifier keys bitmask tracking which modifiers are pressed and, /// where supported by the platform, which side (left or right) is active. /// -/// Modifier side bits ([shiftSide], [ctrlSide], [altSide], [superSide]) are +/// Modifier side bits (`Mods.shiftSide`, `Mods.ctrlSide`, `Mods.altSide`, +/// `Mods.superSide`) are /// only meaningful when the corresponding modifier bit is set. Not all /// platforms distinguish between left and right modifier keys. /// @@ -21,7 +22,7 @@ extension type const Mods._(int value) { const Mods.alt() : value = 1 << 2; /// Right Alt is pressed (0 = left, 1 = right). Only meaningful when - /// [alt] is also set. + /// `Mods.alt` is also set. const Mods.altSide() : value = 1 << 8; /// Caps Lock is active. @@ -31,7 +32,7 @@ extension type const Mods._(int value) { const Mods.ctrl() : value = 1 << 1; /// Right Ctrl is pressed (0 = left, 1 = right). Only meaningful when - /// [ctrl] is also set. + /// `Mods.ctrl` is also set. const Mods.ctrlSide() : value = 1 << 7; @internal @@ -47,14 +48,14 @@ extension type const Mods._(int value) { const Mods.shift() : value = 1 << 0; /// Right Shift is pressed (0 = left, 1 = right). Only meaningful when - /// [shift] is also set. + /// `Mods.shift` is also set. const Mods.shiftSide() : value = 1 << 6; /// Super/Command/Windows key is pressed. const Mods.superKey() : value = 1 << 3; /// Right Super is pressed (0 = left, 1 = right). Only meaningful when - /// [superKey] is also set. + /// `Mods.superKey` is also set. const Mods.superSide() : value = 1 << 9; /// Whether Alt/Option is pressed. @@ -94,9 +95,12 @@ extension type const Mods._(int value) { /// [hasSuper] is true. bool get isSuperRight => value & (1 << 9) != 0; + /// Returns the modifier bits present in both values. Mods operator &(Mods other) => Mods.fromValue(value & other.value); + /// Returns the symmetric difference of the modifier bits. Mods operator ^(Mods other) => Mods.fromValue(value ^ other.value); + /// Returns the union of this value and [other], preserving unknown bits. Mods operator |(Mods other) => Mods.fromValue(value | other.value); } diff --git a/packages/libghostty/lib/src/impl/mouse/mouse_encoder.dart b/packages/libghostty/lib/src/api/mouse/mouse_encoder.dart similarity index 64% rename from packages/libghostty/lib/src/impl/mouse/mouse_encoder.dart rename to packages/libghostty/lib/src/api/mouse/mouse_encoder.dart index eb132c4d..07b0769e 100644 --- a/packages/libghostty/lib/src/impl/mouse/mouse_encoder.dart +++ b/packages/libghostty/lib/src/api/mouse/mouse_encoder.dart @@ -1,16 +1,16 @@ part of '../terminal/terminal.dart'; -/// Encodes mouse events into terminal escape sequences, supporting X10, -/// UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols. +/// Encodes mouse events into terminal escape sequences, supporting X10, UTF-8, +/// SGR, URxvt, and SGR-Pixels mouse protocols. /// /// The encoder is stateful: configure it with the tracking mode, output /// format, and renderer size before encoding. Options can be set individually /// or synced from a [Terminal] via [sync]. /// -/// When used with a [Terminal], call [sync] immediately before each -/// [encode] so the produced sequence matches the terminal's current -/// tracking mode and output format. Call [setSize] once up front and again -/// whenever the grid or cell dimensions change; [sync] does not touch it. +/// When used with a [Terminal], call [sync] immediately before each [encode] +/// so the produced sequence matches the terminal's current tracking mode and +/// output format. Call [setSize] once up front and again whenever the grid or +/// cell dimensions change; [sync] does not touch it. /// /// ```dart /// final terminal = Terminal(cols: 80, rows: 24); @@ -33,29 +33,31 @@ part of '../terminal/terminal.dart'; /// encoder.dispose(); /// terminal.dispose(); /// ``` -@immutable final class MouseEncoder { - static final _finalizer = Finalizer(bindings.mouseEncoderFree); + static final _finalizer = Finalizer(bindings.mouse.mouseEncoderFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new mouse encoder with default options. /// - /// All modes start disabled (no mouse tracking). Configure with [sync] - /// or the typed setter methods, and call [setSize] before encoding. + /// All modes start disabled (no mouse tracking). Configure with [sync] or + /// the typed setter methods, and call [setSize] before encoding. /// /// Throws [OutOfMemoryException] if the native allocation fails. - MouseEncoder() : _handle = check(bindings.mouseEncoderNew()) { + MouseEncoder() : _handle = bindings.mouse.mouseEncoderNew() { _finalizer.attach(this, _handle, detach: this); } /// Releases the native encoder handle. /// - /// Must be called to free resources; the encoder must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. void dispose() { + if (_disposed) return; + bindings.mouse.mouseEncoderFree(_handle); _finalizer.detach(this); - bindings.mouseEncoderFree(_handle); + _disposed = true; } /// Encodes a [MouseEvent] into the appropriate terminal escape sequence @@ -72,29 +74,40 @@ final class MouseEncoder { /// if (seq.isNotEmpty) pty.write(utf8.encode(seq)); /// ``` String encode(MouseEvent event) { - return check(bindings.mouseEncoderEncode(_handle, event._handle)); + final handle = _requireHandle(); + final eventHandle = event._requireHandle(); + return bindings.mouse.mouseEncoderEncode(handle, eventHandle); } /// Clears internal motion deduplication state (last tracked cell). /// /// Call this when the terminal is reset or the viewport changes to avoid /// suppressing motion events that should be re-reported. - void reset() => bindings.mouseEncoderReset(_handle); + void reset() { + final handle = _requireHandle(); + bindings.mouse.mouseEncoderReset(handle); + } /// Sets whether any mouse button is currently pressed. /// /// The encoder uses this to distinguish drag events from plain motion. void setAnyButtonPressed({required bool pressed}) { - bindings.mouseEncoderSetBoolOpt(_handle, .anyButtonPressed, value: pressed); + final handle = _requireHandle(); + bindings.mouse.mouseEncoderSetBoolOpt( + handle, + .anyButtonPressed, + value: pressed, + ); } /// Sets the mouse output format (X10, UTF-8, SGR, URxvt, or SGR-Pixels). /// /// Controls how mouse coordinates and buttons are encoded in the escape - /// sequence. Typically synced from the terminal via [sync], but can be - /// set directly. + /// sequence. Typically synced from the terminal via [sync], but can be set + /// directly. void setFormat(MouseFormat format) { - bindings.mouseEncoderSetFormat(_handle, format); + final handle = _requireHandle(); + bindings.mouse.mouseEncoderSetFormat(handle, format); } /// Sets the renderer size context for pixel-to-cell coordinate conversion. @@ -104,7 +117,8 @@ final class MouseEncoder { /// again whenever the terminal grid dimensions or cell size change; /// [encode] needs this to produce a non-empty sequence. void setSize(MouseEncoderSize size) { - bindings.mouseEncoderSetSize(_handle, size); + final handle = _requireHandle(); + bindings.mouse.mouseEncoderSetSize(handle, size); } /// Sets the mouse tracking mode (none, X10, normal, button, or any). @@ -112,24 +126,37 @@ final class MouseEncoder { /// Controls which mouse events are reported. Typically synced from the /// terminal via [sync], but can be set directly. void setTrackingMode(MouseTracking mode) { - bindings.mouseEncoderSetTrackingMode(_handle, mode); + final handle = _requireHandle(); + bindings.mouse.mouseEncoderSetTrackingMode(handle, mode); } /// Sets whether to enable motion deduplication by last cell. /// - /// When enabled, consecutive motion events that resolve to the same cell - /// are suppressed. Call [reset] to clear the deduplication state. + /// When enabled, consecutive motion events that resolve to the same cell are + /// suppressed. Call [reset] to clear the deduplication state. void setTrackLastCell({required bool enabled}) { - bindings.mouseEncoderSetBoolOpt(_handle, .trackLastCell, value: enabled); + final handle = _requireHandle(); + bindings.mouse.mouseEncoderSetBoolOpt( + handle, + .trackLastCell, + value: enabled, + ); } /// Syncs tracking mode and output format from [terminal]'s current state. /// /// Reads the terminal's mouse tracking mode and output format and applies /// them to the encoder. Call immediately before each [encode] so the - /// produced sequence matches the terminal's current state. Does not - /// modify size or any-button state. + /// produced sequence matches the terminal's current state. Does not modify + /// size or any-button state. void sync(Terminal terminal) { - bindings.mouseEncoderSetOptFromTerminal(_handle, terminal._handle); + final handle = _requireHandle(); + final terminalHandle = terminal._terminalHandle; + bindings.mouse.mouseEncoderSetOptFromTerminal(handle, terminalHandle); + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) throw StateError('MouseEncoder has been disposed'); + return _handle; } } diff --git a/packages/libghostty/lib/src/impl/mouse/mouse_event.dart b/packages/libghostty/lib/src/api/mouse/mouse_event.dart similarity index 57% rename from packages/libghostty/lib/src/impl/mouse/mouse_event.dart rename to packages/libghostty/lib/src/api/mouse/mouse_event.dart index 639a25b2..6407b4a4 100644 --- a/packages/libghostty/lib/src/impl/mouse/mouse_event.dart +++ b/packages/libghostty/lib/src/api/mouse/mouse_event.dart @@ -22,11 +22,11 @@ part of '../terminal/terminal.dart'; /// /// event.dispose(); /// ``` -@immutable final class MouseEvent { - static final _finalizer = Finalizer(bindings.mouseEventFree); + static final _finalizer = Finalizer(bindings.mouse.mouseEventFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new mouse event with default values. /// @@ -34,58 +34,87 @@ final class MouseEvent { /// [setPosition]) before passing to [MouseEncoder.encode]. /// /// Throws [OutOfMemoryException] if the native allocation fails. - MouseEvent() : _handle = check(bindings.mouseEventNew()) { + MouseEvent() : _handle = bindings.mouse.mouseEventNew() { _finalizer.attach(this, _handle, detach: this); } /// The mouse action: [MouseAction.press], [MouseAction.release], or /// [MouseAction.motion]. - MouseAction get action => bindings.mouseEventGetAction(_handle); + MouseAction get action { + final handle = _requireHandle(); + return bindings.mouse.mouseEventGetAction(handle); + } /// Sets the mouse action. - set action(MouseAction value) => bindings.mouseEventSetAction(_handle, value); + set action(MouseAction value) { + final handle = _requireHandle(); + bindings.mouse.mouseEventSetAction(handle, value); + } /// The mouse button, or null if no button is set. /// /// Returns null for motion events with no button pressed. Use /// [clearButton] to represent "no button". MouseButton? get button { - final (code, button) = bindings.mouseEventGetButton(_handle); - return code == .noValue ? null : button; + final handle = _requireHandle(); + return bindings.mouse.mouseEventGetButton(handle); } /// Sets a concrete button identity for the event. /// - /// To represent "no button" (for motion events without a button held), - /// use [clearButton] instead. - set button(MouseButton value) => bindings.mouseEventSetButton(_handle, value); + /// To represent "no button" (for motion events without a button held), use + /// [clearButton] instead. + set button(MouseButton value) { + final handle = _requireHandle(); + bindings.mouse.mouseEventSetButton(handle, value); + } /// Keyboard modifiers held during the mouse event. - Mods get mods => Mods.fromValue(bindings.mouseEventGetMods(_handle)); + Mods get mods { + final handle = _requireHandle(); + return Mods.fromValue(bindings.mouse.mouseEventGetMods(handle)); + } /// Sets the keyboard modifiers held during the event. - set mods(Mods value) => bindings.mouseEventSetMods(_handle, value.value); + set mods(Mods value) { + final handle = _requireHandle(); + bindings.mouse.mouseEventSetMods(handle, value.value); + } /// Surface-space pixel coordinates of the mouse event. - (double x, double y) get position => bindings.mouseEventGetPosition(_handle); + (double x, double y) get position { + final handle = _requireHandle(); + return bindings.mouse.mouseEventGetPosition(handle); + } /// Clears the button to "none". /// /// Use this for motion events where no button is pressed. The [button] /// getter will return null after this call. - void clearButton() => bindings.mouseEventClearButton(_handle); + void clearButton() { + final handle = _requireHandle(); + bindings.mouse.mouseEventClearButton(handle); + } /// Releases the native mouse event handle. /// - /// Must be called to free resources; the event must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. void dispose() { + if (_disposed) return; + bindings.mouse.mouseEventFree(_handle); _finalizer.detach(this); - bindings.mouseEventFree(_handle); + _disposed = true; } /// Sets the event position in surface-space pixels. void setPosition({required double x, required double y}) { - bindings.mouseEventSetPosition(_handle, x, y); + final handle = _requireHandle(); + bindings.mouse.mouseEventSetPosition(handle, x, y); + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) throw StateError('MouseEvent has been disposed'); + return _handle; } } diff --git a/packages/libghostty/lib/src/impl/osc_parser.dart b/packages/libghostty/lib/src/api/osc_parser.dart similarity index 57% rename from packages/libghostty/lib/src/impl/osc_parser.dart rename to packages/libghostty/lib/src/api/osc_parser.dart index 2468697e..f64c475f 100644 --- a/packages/libghostty/lib/src/impl/osc_parser.dart +++ b/packages/libghostty/lib/src/api/osc_parser.dart @@ -1,14 +1,13 @@ -import 'package:meta/meta.dart'; - import '../bindings/bindings.dart'; -import '../ffi/libghostty_enums.g.dart'; +import '../bindings/types.dart'; +import '../generated/libghostty_enums.g.dart'; /// The result of parsing an OSC sequence. /// /// Query [type] to determine what command was parsed, then read the /// corresponding data field (e.g. [windowTitle] for /// [OscCommandType.changeWindowTitle]). -class OscCommand { +final class OscCommand { /// The parsed command type, or [OscCommandType.invalid] if the sequence /// was malformed. final OscCommandType type; @@ -16,11 +15,12 @@ class OscCommand { /// The window title from a [OscCommandType.changeWindowTitle] command, /// or null for other command types. /// - /// Valid until the next call to any method on the same [OscParser] - /// instance (except [OscParser.dispose]). Memory is owned by the parser. + /// This is a Dart-owned snapshot of the title and remains valid after the + /// parser is reused or disposed. final String? windowTitle; - OscCommand({required this.type, this.windowTitle}); + /// Creates a parsed OSC command value. + const OscCommand({required this.type, this.windowTitle}); } /// Streaming parser for OSC (Operating System Command) sequences. @@ -46,26 +46,28 @@ class OscCommand { /// /// parser.dispose(); /// ``` -@immutable final class OscParser { - static final _finalizer = Finalizer(bindings.oscFree); + static final _finalizer = Finalizer(bindings.parser.oscFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new OSC parser. /// /// Throws [OutOfMemoryException] if the native allocation fails. - OscParser() : _handle = check(bindings.oscNew()) { + OscParser() : _handle = bindings.parser.oscNew() { _finalizer.attach(this, _handle, detach: this); } /// Releases the native parser handle. /// - /// Must be called to free resources; the parser must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. void dispose() { + if (_disposed) return; + bindings.parser.oscFree(_handle); _finalizer.detach(this); - bindings.oscFree(_handle); + _disposed = true; } /// Finalizes parsing and returns the parsed command. @@ -74,21 +76,20 @@ final class OscParser { /// [feedByte] or [feedBytes]. Do not include the opening ESC ] or the /// terminating character (BEL or ST) in the fed bytes. /// - /// [terminator] is the byte that terminated the OSC sequence (typically - /// 0x07 for BEL or 0x5C for ST after ESC). This is preserved in the - /// parsed command so that responses can use the same terminator format - /// for compatibility. For commands that do not require a response, this + /// [terminator] is the byte that terminated the OSC sequence, typically + /// 0x07 for BEL or 0x5C for ST after ESC. This is preserved in the parsed + /// command so that responses can use the same terminator format for + /// compatibility. For commands that do not require a response, this /// parameter is ignored. /// /// Always returns a result. Invalid or unrecognized sequences produce a - /// command with type [OscCommandType.invalid]. The returned command data - /// is valid until the next call to any method on this parser (except - /// [dispose]). + /// command with type [OscCommandType.invalid]. OscCommand end(int terminator) { - final command = bindings.oscEnd(_handle, terminator); - final type = bindings.oscCommandType(command); + final handle = _requireHandle(); + final command = bindings.parser.oscEnd(handle, terminator); + final type = bindings.parser.oscCommandType(command); final windowTitle = switch (type) { - .changeWindowTitle => bindings.oscCommandWindowTitle(command), + .changeWindowTitle => bindings.parser.oscCommandWindowTitle(command), _ => null, }; return OscCommand(type: type, windowTitle: windowTitle); @@ -96,16 +97,20 @@ final class OscParser { /// Feeds a single byte to the parser. /// - /// Call for each byte in the OSC sequence body (after the opening ESC ] - /// and before the terminator). - void feedByte(int byte) => bindings.oscFeedByte(_handle, byte); + /// Call for each byte in the OSC sequence body, after the opening ESC ] + /// and before the terminator. + void feedByte(int byte) { + final handle = _requireHandle(); + bindings.parser.oscFeedByte(handle, byte); + } /// Feeds multiple bytes to the parser. /// - /// Convenience method that calls [feedByte] for each byte in [bytes]. + /// This is equivalent to calling [feedByte] for each byte in [bytes]. void feedBytes(List bytes) { + final handle = _requireHandle(); for (final byte in bytes) { - bindings.oscFeedByte(_handle, byte); + bindings.parser.oscFeedByte(handle, byte); } } @@ -113,5 +118,11 @@ final class OscParser { /// /// Clears any partially parsed sequence. Useful for reusing the parser /// or recovering from parse errors. - void reset() => bindings.oscReset(_handle); + void reset() { + final handle = _requireHandle(); + bindings.parser.oscReset(handle); + } + + LibGhosttyHandle _requireHandle() => + _disposed ? throw StateError('OscParser has been disposed') : _handle; } diff --git a/packages/libghostty/lib/src/impl/paste.dart b/packages/libghostty/lib/src/api/paste.dart similarity index 88% rename from packages/libghostty/lib/src/impl/paste.dart rename to packages/libghostty/lib/src/api/paste.dart index 03970acd..8646cdf6 100644 --- a/packages/libghostty/lib/src/impl/paste.dart +++ b/packages/libghostty/lib/src/api/paste.dart @@ -14,14 +14,12 @@ import '../bindings/bindings.dart'; /// Use [pasteIsSafe] first to check whether the data should be pasted at all /// (e.g. to prompt the user for confirmation on multi-line pastes). /// -/// Throws [LibGhosttyException] if encoding fails. -/// /// ```dart /// final encoded = pasteEncode('hello\nworld', bracketed: true); /// terminal.write(encoded); /// ``` Uint8List pasteEncode(String data, {required bool bracketed}) { - return check(bindings.pasteEncode(data, bracketed: bracketed)); + return bindings.utility.pasteEncode(data, bracketed: bracketed); } /// Checks whether [data] is safe to paste into a terminal without user @@ -40,4 +38,4 @@ Uint8List pasteEncode(String data, {required bool bracketed}) { /// pasteIsSafe('rm -rf /\n'); // false /// pasteIsSafe('\x1b[201~injected'); // false /// ``` -bool pasteIsSafe(String data) => bindings.pasteIsSafe(data); +bool pasteIsSafe(String data) => bindings.utility.pasteIsSafe(data); diff --git a/packages/libghostty/lib/src/impl/sgr_parser.dart b/packages/libghostty/lib/src/api/sgr_parser.dart similarity index 60% rename from packages/libghostty/lib/src/impl/sgr_parser.dart rename to packages/libghostty/lib/src/api/sgr_parser.dart index 49227b17..3257d431 100644 --- a/packages/libghostty/lib/src/impl/sgr_parser.dart +++ b/packages/libghostty/lib/src/api/sgr_parser.dart @@ -1,6 +1,6 @@ -import 'package:meta/meta.dart'; - import '../bindings/bindings.dart'; +import '../bindings/types.dart'; +import '../types/types.dart'; /// Parses SGR (Select Graphic Rendition) escape sequence parameters into /// typed [SgrAttribute] values. @@ -20,38 +20,41 @@ import '../bindings/bindings.dart'; /// // attrs: [SgrAttribute(tag: .bold), SgrAttribute(tag: .directColorFg, ...)] /// parser.dispose(); /// ``` -@immutable final class SgrParser { - static final _finalizer = Finalizer(bindings.sgrFree); + static final _finalizer = Finalizer(bindings.parser.sgrFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a new SGR parser. /// - /// Throws [OutOfMemoryException] if the native allocation fails. - SgrParser() : _handle = check(bindings.sgrNew()) { + /// Throws [OutOfMemoryException] if the native allocation fails during + /// construction. + SgrParser() : _handle = bindings.parser.sgrNew() { _finalizer.attach(this, _handle, detach: this); } /// Releases the native parser handle. /// - /// Must be called to free resources; the parser must not be used - /// afterward. Any [SgrAttribute] values previously returned by [parse] - /// become invalid as well. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. Any [SgrAttribute] values previously + /// returned by [parse] remain valid because they are Dart-owned values. void dispose() { + if (_disposed) return; + bindings.parser.sgrFree(_handle); _finalizer.detach(this); - bindings.sgrFree(_handle); + _disposed = true; } /// Parses SGR [params] into a list of typed attributes. /// - /// [params] are the numeric values from a CSI SGR sequence (e.g. for - /// `ESC[1;31m`, params would be `[1, 31]`). + /// [params] are the numeric values from a CSI SGR sequence, for example, + /// `[1, 31]` for `ESC[1;31m`. /// /// [separators] optionally specifies the separator character for each /// parameter position: `";"` for semicolon or `":"` for colon. This is - /// needed for color formats that use colon separators (e.g. `ESC[4:3m` - /// for curly underline). Must have the same length as [params] if + /// needed for color formats that use colon separators, such as `ESC[4:3m` + /// for curly underline. It must have the same length as [params] when /// provided. If null, all parameters are assumed to be /// semicolon-separated. /// @@ -59,18 +62,14 @@ final class SgrParser { /// [separators] can be modified after this call. /// /// Throws [OutOfMemoryException] if the internal copy allocation fails. - /// - /// ```dart - /// // Curly underline with colon separator - /// final attrs = parser.parse([4, 3], separators: [':', ':']); - /// ``` List parse(List params, {List? separators}) { - checkCode(bindings.sgrSetParams(_handle, params, separators)); + final handle = _requireHandle(); + bindings.parser.sgrSetParams(handle, params, separators); final results = []; for ( - var attr = bindings.sgrNext(_handle); + var attr = bindings.parser.sgrNext(handle); attr != null; - attr = bindings.sgrNext(_handle) + attr = bindings.parser.sgrNext(handle) ) { results.add(attr); } @@ -80,7 +79,13 @@ final class SgrParser { /// Resets the parser's iteration state to the beginning of the parameter /// list without clearing the parameters. /// - /// After calling this, the next [parse] or internal iteration will start - /// from the beginning. - void reset() => bindings.sgrReset(_handle); + /// After calling this, the next [parse] or internal iteration starts from + /// the beginning. + void reset() { + final handle = _requireHandle(); + bindings.parser.sgrReset(handle); + } + + LibGhosttyHandle _requireHandle() => + _disposed ? throw StateError('SgrParser has been disposed') : _handle; } diff --git a/packages/libghostty/lib/src/impl/sys.dart b/packages/libghostty/lib/src/api/sys.dart similarity index 58% rename from packages/libghostty/lib/src/impl/sys.dart rename to packages/libghostty/lib/src/api/sys.dart index 4fb1b466..72ed1e02 100644 --- a/packages/libghostty/lib/src/impl/sys.dart +++ b/packages/libghostty/lib/src/api/sys.dart @@ -1,4 +1,5 @@ import '../bindings/bindings.dart'; +import '../types/aliases.dart'; /// Callback invoked by libghostty to emit an internal log message. /// @@ -8,8 +9,11 @@ import '../bindings/bindings.dart'; /// the native library; release builds compile those calls out entirely. /// /// Byte slices received from the C ABI are already decoded into Dart -/// strings before this callback runs. The callback may be invoked from -/// any thread. +/// strings before this callback runs. Native log delivery may originate on any +/// thread and is dispatched asynchronously to the isolate that registered the +/// callback. A queued message is delivered to whichever logger is installed +/// when it reaches the Dart event loop. User errors are reported through that +/// registration zone's uncaught error handler. typedef LogCallback = SysLogCallback; /// Process-global configuration hooks for the native libghostty library. @@ -27,12 +31,30 @@ typedef LogCallback = SysLogCallback; /// } /// ``` abstract final class LibGhostty { - /// Clears the installed logger and releases resources held by the - /// Dart-side callback trampoline. + /// Clears the installed logger and stops delivering messages to Dart. /// - /// Safe to call multiple times. After this, log output is silently - /// discarded until another logger is installed. - static void clearLogger() => bindings.sysClearLogCallback(); + /// Safe to call multiple times. The process-global native transport remains + /// alive so a native thread cannot call a closed function pointer. After + /// this, log output is silently discarded until another logger is installed. + static void clearLogger() => bindings.system.sysClearLogCallback(); + + /// Clears the installed PNG decoder. + /// + /// Safe to call multiple times. After this, PNG payloads are + /// rejected until another decoder is installed. On WebAssembly, the + /// callback-table slot is released after the C option is cleared. Native + /// keeps its callback trampoline alive for the binding lifetime because the + /// C ABI does not provide a quiescence operation for in-flight callbacks. + static void clearPngDecoder() => bindings.system.sysClearPngDecoder(); + + /// Installs [logger] as the sink for internal libghostty log messages. + /// + /// Replaces any previously installed logger (including the one set by + /// [useStderrLogger]). Use [clearLogger] to stop receiving log messages; + /// with no logger installed, log output is silently discarded. + static void setLogger(LogCallback logger) { + bindings.system.sysSetLogCallback(logger); + } /// Installs [decoder] as the PNG decoder invoked by libghostty when a /// Kitty graphics payload arrives in PNG form. @@ -43,41 +65,30 @@ abstract final class LibGhostty { /// The callback returns null to signal a decode failure, which is /// treated the same as having no decoder installed for that payload. /// - /// The [DecodedImage.rgba] buffer is copied into a library-owned + /// The `DecodedImage.rgba` buffer is copied into a library-owned /// allocation before the callback returns, so the caller's buffer - /// lifetime is not a concern. The callback may be invoked from any - /// thread. + /// lifetime is not a concern. PNG decoding runs synchronously on the thread + /// that is processing the terminal operation. Serialize decoder + /// registration, terminal use, and decoder clearing with that operation. /// /// ```dart /// LibGhostty.setPngDecoder((pngBytes) { /// final decoded = decodePngToRgba(pngBytes); /// if (decoded == null) return null; - /// return (width: decoded.w, height: decoded.h, rgba: decoded.pixels); + /// return DecodedImage( + /// width: decoded.w, + /// height: decoded.h, + /// rgba: decoded.pixels, + /// ); /// }); /// ``` static void setPngDecoder(PngDecoder decoder) => - bindings.sysSetPngDecoder(decoder); - - /// Clears the installed PNG decoder and releases resources held by - /// the Dart-side callback trampoline. - /// - /// Safe to call multiple times. After this, PNG payloads are - /// rejected until another decoder is installed. - static void clearPngDecoder() => bindings.sysClearPngDecoder(); - - /// Installs [logger] as the sink for internal libghostty log messages. - /// - /// Replaces any previously installed logger (including the one set by - /// [useStderrLogger]). Use [clearLogger] to stop receiving log messages; - /// with no logger installed, log output is silently discarded. - static void setLogger(LogCallback logger) { - bindings.sysSetLogCallback(logger); - } + bindings.system.sysSetPngDecoder(decoder); /// Installs the native library's built-in stderr log sink. /// /// Each message is formatted as `[level](scope): message` and written /// to stderr. Equivalent to registering a logger that delegates to /// `ghostty_sys_log_stderr`. Replaces any previously installed logger. - static void useStderrLogger() => bindings.sysSetLogToStderr(); + static void useStderrLogger() => bindings.system.sysSetLogToStderr(); } diff --git a/packages/libghostty/lib/src/impl/terminal/cell_iterator.dart b/packages/libghostty/lib/src/api/terminal/cell_iterator.dart similarity index 70% rename from packages/libghostty/lib/src/impl/terminal/cell_iterator.dart rename to packages/libghostty/lib/src/api/terminal/cell_iterator.dart index 5009aad1..fc2c2259 100644 --- a/packages/libghostty/lib/src/impl/terminal/cell_iterator.dart +++ b/packages/libghostty/lib/src/api/terminal/cell_iterator.dart @@ -10,6 +10,10 @@ part of 'terminal.dart'; /// Bind to a row with [reset]; call [reset] again after each /// [RowIterator.next] or [RenderState.update] so the iterator tracks the /// current row. +/// Access after the row advances, after a render update, or before a +/// successful [next] or [select] throws [StateError]. +/// Calling [dispose] more than once is safe; every other member throws +/// [StateError] after disposal. /// /// ```dart /// final cells = CellIterator(); @@ -23,11 +27,14 @@ part of 'terminal.dart'; /// } /// ``` final class CellIterator { - static final _finalizer = Finalizer(bindings.rowCellsFree); + static final _finalizer = Finalizer(bindings.render.rowCellsFree); - final int _handle; + final LibGhosttyHandle _handle; - var _rawCell = 0; + var _disposed = false; + RowIterator? _rowIterator; + var _rowPositionGeneration = 0; + var _rawCell = const LibGhosttyHandle.fromAddress(0); var _graphemeLen = 0; var _codepoint = 0; var _styleId = -1; @@ -44,7 +51,7 @@ final class CellIterator { /// /// Must be populated with [reset] before [next] or [select] is called. /// Throws [OutOfMemoryException] if the native allocation fails. - CellIterator() : _handle = check(bindings.rowCellsNew()) { + CellIterator() : _handle = bindings.render.rowCellsNew() { _finalizer.attach(this, _handle, detach: this); } @@ -53,14 +60,14 @@ final class CellIterator { /// active palette; when null, the caller should use the terminal's /// default background. RgbColor? get background { - final (code, rgb) = bindings.rowCellsGetBgColor(_handle); - return code == .success ? rgb : null; + _ensureCurrent(); + return bindings.render.rowCellsGetBgColor(_handle); } /// Resolved background as packed ARGB int, or null if unset. int? get backgroundArgb { - final (code, argb) = bindings.rowCellsGetBgColorArgb(_handle); - return code == .success ? argb : null; + _ensureCurrent(); + return bindings.render.rowCellsGetBgColorArgb(_handle); } /// Primary codepoint of the current cell, or 0 if the cell has no text. @@ -72,7 +79,10 @@ final class CellIterator { /// Column index of the current cell within the row (zero-based). /// /// Undefined before the first successful [next] or [select] call. - int get col => _col; + int get col { + _ensureCurrent(); + return _col; + } /// Full grapheme cluster of the current cell as a string, or empty if /// the cell has no text. @@ -81,7 +91,7 @@ final class CellIterator { if (_graphemeLen == 0) return ''; if (_graphemeLen == 1) return String.fromCharCode(_codepoint); return String.fromCharCodes( - check(bindings.rowCellsGetGraphemes(_handle, _graphemeLen)), + bindings.render.rowCellsGetGraphemes(_handle, _graphemeLen), ); } @@ -91,14 +101,14 @@ final class CellIterator { /// styling separately. When null, the caller should use the terminal's /// default foreground. RgbColor? get foreground { - final (code, rgb) = bindings.rowCellsGetFgColor(_handle); - return code == .success ? rgb : null; + _ensureCurrent(); + return bindings.render.rowCellsGetFgColor(_handle); } /// Resolved foreground as packed ARGB int, or null if unset. int? get foregroundArgb { - final (code, argb) = bindings.rowCellsGetFgColorArgb(_handle); - return code == .success ? argb : null; + _ensureCurrent(); + return bindings.render.rowCellsGetFgColorArgb(_handle); } /// Number of codepoints in the current cell's grapheme cluster (0 = @@ -111,11 +121,14 @@ final class CellIterator { /// Whether the current cell has a hyperlink (OSC 8). bool get hasHyperlink { _ensureText(); - return check(bindings.cellGetHasHyperlink(_rawCell)); + return bindings.render.cellGetHasHyperlink(_rawCell); } /// Whether the current cell has non-default styling attributes. - bool get hasStyling => check(bindings.rowCellsGetHasStyling(_handle)); + bool get hasStyling { + _ensureCurrent(); + return bindings.render.rowCellsGetHasStyling(_handle); + } /// Whether the current cell contains any text. bool get hasText { @@ -126,7 +139,7 @@ final class CellIterator { /// Whether the current cell is protected (DECSCA). bool get isProtected { _ensureText(); - return check(bindings.cellGetProtected(_rawCell)); + return bindings.render.cellGetProtected(_rawCell); } /// Whether the current cell is contained within the current selection. @@ -135,8 +148,9 @@ final class CellIterator { /// row-local selection range, and false otherwise. Rendering colors, /// inversion, etc are caller policy. bool get isSelected { + _ensureCurrent(); if (!_selectedValid) { - _isSelected = check(bindings.rowCellsGetSelected(_handle)); + _isSelected = bindings.render.rowCellsGetSelected(_handle); _selectedValid = true; } return _isSelected; @@ -145,7 +159,7 @@ final class CellIterator { /// Semantic content type of the current cell. SemanticContent get semanticContent { _ensureText(); - return check(bindings.cellGetSemanticContent(_rawCell)); + return bindings.render.cellGetSemanticContent(_rawCell); } /// Style of the current cell. Cached per style id to avoid redundant @@ -154,7 +168,7 @@ final class CellIterator { _ensureMetadata(); if (_styleId != _prevStyleId) { _prevStyleId = _styleId; - _cachedStyle = check(bindings.rowCellsGetStyle(_handle)); + _cachedStyle = bindings.render.rowCellsGetStyle(_handle); } return _cachedStyle; } @@ -174,19 +188,19 @@ final class CellIterator { } /// Releases the native iterator handle. - /// - /// Must be called to free resources; the iterator must not be used - /// afterward. void dispose() { + if (_disposed) return; + bindings.render.rowCellsFree(_handle); _finalizer.detach(this); - bindings.rowCellsFree(_handle); + _disposed = true; } /// Advances to the next cell. Returns true when a cell is available and /// the getter properties reflect it; returns false when the row is /// exhausted. bool next() { - if (!bindings.rowCellsNext(_handle)) return false; + _ensureCurrent(); + if (!bindings.render.rowCellsNext(_handle)) return false; _col++; _invalidate(); return true; @@ -199,7 +213,11 @@ final class CellIterator { /// recent [RowIterator.next] must have returned true). Subsequent /// [next] / [select] calls read cells from that row. void reset(RowIterator rowIterator) { - checkCode(bindings.rowCellsInit(_handle, rowIterator._handle)); + _ensureAlive(); + rowIterator._ensurePositioned(); + bindings.render.rowCellsInit(_handle, rowIterator._handle); + _rowIterator = rowIterator; + _rowPositionGeneration = rowIterator._positionGeneration; _col = -1; _prevStyleId = -1; _invalidate(); @@ -213,16 +231,35 @@ final class CellIterator { /// /// Throws [InvalidValueException] if [col] is out of range. void select(int col) { - checkCode(bindings.rowCellsSelect(_handle, col)); + _ensureCurrent(); + bindings.render.rowCellsSelect(_handle, col); _col = col; _invalidate(); } + void _ensureAlive() { + if (_disposed) throw StateError('CellIterator has been disposed'); + } + + void _ensureCurrent() { + _ensureAlive(); + final rowIterator = _rowIterator; + if (rowIterator == null) { + throw StateError('CellIterator has not been bound to a row'); + } + rowIterator._ensurePositioned(); + if (rowIterator._positionGeneration != _rowPositionGeneration) { + throw StateError('CellIterator has been invalidated by a row update'); + } + } + void _ensureMetadata() { + _ensureCurrent(); if (!_metadataValid) _refreshMetadata(); } void _ensureText() { + _ensureCurrent(); if (!_textValid) _refreshMetadata(); } @@ -233,13 +270,13 @@ final class CellIterator { } void _refreshMetadata() { - final rowCell = check(bindings.rowCellsGetSummary(_handle)); - _rawCell = rowCell.rawCell; + final rowCell = bindings.render.rowCellsGetSummary(_handle); + _rawCell = .fromAddress(rowCell.rawCell); _graphemeLen = rowCell.graphemeLen; _isSelected = rowCell.selected; _selectedValid = true; - final cell = check(bindings.cellGetSummary(_rawCell)); + final cell = bindings.render.cellGetSummary(_rawCell); _styleId = cell.styleId; _codepoint = _graphemeLen > 0 ? cell.codepoint : 0; _wide = cell.wide; diff --git a/packages/libghostty/lib/src/impl/terminal/formatter.dart b/packages/libghostty/lib/src/api/terminal/formatter.dart similarity index 71% rename from packages/libghostty/lib/src/impl/terminal/formatter.dart rename to packages/libghostty/lib/src/api/terminal/formatter.dart index 84a07b50..c273385a 100644 --- a/packages/libghostty/lib/src/impl/terminal/formatter.dart +++ b/packages/libghostty/lib/src/api/terminal/formatter.dart @@ -4,6 +4,8 @@ part of 'terminal.dart'; /// /// Captures a reference to a [Terminal] and reads its current state on each /// [format] call. The [Terminal] must outlive this formatter. +/// Calling [dispose] more than once is safe; [format] throws [StateError] +/// after disposal. /// /// ```dart /// final formatter = Formatter( @@ -13,11 +15,11 @@ part of 'terminal.dart'; /// final text = formatter.format(); /// formatter.dispose(); /// ``` -@immutable final class Formatter { - static final _finalizer = Finalizer(bindings.formatterFree); + static final _finalizer = Finalizer(bindings.formatter.formatterFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; /// Creates a formatter for [terminal]. /// @@ -41,12 +43,11 @@ final class Formatter { } /// Releases the native formatter handle. - /// - /// Must be called to free resources; the formatter must not be used - /// afterward. void dispose() { + if (_disposed) return; + bindings.formatter.formatterFree(_handle); _finalizer.detach(this); - bindings.formatterFree(_handle); + _disposed = true; } /// Formats the terminal's current active screen content and returns the @@ -56,9 +57,15 @@ final class Formatter { /// after a [Terminal.write] reflects the updated content. /// /// Throws [OutOfMemoryException] if the output buffer allocation fails. - String format() => check(bindings.formatterFormat(_handle)); + String format() { + final handle = _requireHandle(); + return bindings.formatter.formatterFormat(handle); + } + + LibGhosttyHandle _requireHandle() => + _disposed ? throw StateError('Formatter has been disposed') : _handle; - static int _create( + static LibGhosttyHandle _create( Terminal terminal, FormatterFormat format, bool unwrap, @@ -67,14 +74,12 @@ final class Formatter { Selection? selection, ) { if (selection == null) { - return check( - bindings.formatterTerminalNew( - terminal._handle, - format, - unwrap: unwrap, - trim: trim, - extra: extra, - ), + return bindings.formatter.formatterTerminalNew( + terminal._terminalHandle, + format, + unwrap: unwrap, + trim: trim, + extra: extra, ); } @@ -86,15 +91,13 @@ final class Formatter { ); } - return check( - bindings.formatterTerminalNew( - terminal._handle, - format, - unwrap: unwrap, - trim: trim, - extra: extra, - selection: selection._raw, - ), + return bindings.formatter.formatterTerminalNew( + terminal._terminalHandle, + format, + unwrap: unwrap, + trim: trim, + extra: extra, + selection: selection._raw, ); } } diff --git a/packages/libghostty/lib/src/impl/terminal/grid_ref.dart b/packages/libghostty/lib/src/api/terminal/grid_ref.dart similarity index 72% rename from packages/libghostty/lib/src/impl/terminal/grid_ref.dart rename to packages/libghostty/lib/src/api/terminal/grid_ref.dart index bbf0f742..267bf950 100644 --- a/packages/libghostty/lib/src/impl/terminal/grid_ref.dart +++ b/packages/libghostty/lib/src/api/terminal/grid_ref.dart @@ -3,8 +3,9 @@ part of 'terminal.dart'; /// A resolved reference to a specific cell position in the terminal grid. /// /// Created via [GridRef.at]. A grid reference is only valid until the next -/// operation on the terminal instance, including seemingly unrelated -/// operations, so cache any needed information right after creation. +/// mutating operation on the terminal instance, including seemingly unrelated +/// operations. Read or copy the needed information before mutating the +/// terminal, then create a new reference afterward. /// /// Not intended for render loops. Use [RenderState] with [RowIterator] and /// [CellIterator] for performance-critical rendering. @@ -35,16 +36,15 @@ final class GridRef { }) => GridRef._(terminal, position, pointTag: pointTag); GridRef._(Terminal terminal, Position position, {PointTag pointTag = .active}) - : this._fromValue( - terminal, - check(bindings.terminalGridRef(terminal._handle, pointTag, position)), + : _terminal = terminal, + _value = bindings.render.terminalGridRef( + terminal._terminalHandle, + pointTag, + position, ); const GridRef._fromValue(this._terminal, this._value); - /// The raw cell handle at this position. - int get cell => check(bindings.gridRefCell(_value)); - /// The cell's full grapheme cluster as a string, or empty if the cell /// has no text. String get content { @@ -55,7 +55,7 @@ final class GridRef { /// The cell's grapheme cluster as a list of Unicode codepoints. The /// primary codepoint is first, followed by any combining codepoints. /// Empty if the cell has no text. - List get graphemes => check(bindings.gridRefGraphemes(_value)); + List get graphemes => bindings.render.gridRefGraphemes(_value); @override int get hashCode => Object.hash(GridRef, _terminal, _value); @@ -63,27 +63,26 @@ final class GridRef { /// The hyperlink URI at this position, or null if the cell has no /// hyperlink. String? get hyperlinkUri { - final (code, uri) = bindings.gridRefHyperlinkUri(_value); - if (code == Result.noValue) return null; - checkCode(code); - return uri.isEmpty ? null : uri; + final uri = bindings.render.gridRefHyperlinkUri(_value); + return uri?.isEmpty ?? true ? null : uri; } /// Whether the cell is the first cell of a wide character. bool get isWide => wide == CellWidth.wide; - /// The raw row handle at this position. - int get row => check(bindings.gridRefRow(_value)); - /// Whether this row is soft-wrapped to the next row. - bool get rowWrap => check(bindings.rowGetWrap(row)); + bool get rowWrap => bindings.render.rowGetWrap(_row); /// The [Style] of the cell at this position. - Style get style => check(bindings.gridRefStyle(_value)); + Style get style => bindings.render.gridRefStyle(_value); /// The cell's width: [CellWidth.narrow], [CellWidth.wide], or /// [CellWidth.spacerTail]. - CellWidth get wide => check(bindings.cellGetWide(cell)); + CellWidth get wide => bindings.render.cellGetWide(_cell); + + LibGhosttyHandle get _cell => bindings.render.gridRefCell(_value); + + LibGhosttyHandle get _row => bindings.render.gridRefRow(_value); @override bool operator ==(Object other) => @@ -96,13 +95,11 @@ final class GridRef { /// system (e.g. a scrollback row cannot be expressed in active /// coordinates). Position? positionIn(PointTag pointTag) { - final (code, position) = bindings.terminalPointFromGridRef( - _terminal._handle, + final position = bindings.render.terminalPointFromGridRef( + _terminal._terminalHandle, _value, pointTag, ); - if (code == Result.noValue) return null; - checkCode(code); return position; } diff --git a/packages/libghostty/lib/src/api/terminal/kitty_graphics.dart b/packages/libghostty/lib/src/api/terminal/kitty_graphics.dart new file mode 100644 index 00000000..e842390e --- /dev/null +++ b/packages/libghostty/lib/src/api/terminal/kitty_graphics.dart @@ -0,0 +1,184 @@ +part of 'terminal.dart'; + +/// Image storage associated with a terminal's active screen, exposing the +/// images and placements stored via the +/// [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). +/// +/// Obtained via [KittyGraphics.of]. The handle is borrowed from the +/// terminal and is invalidated by any mutating terminal call +/// ([Terminal.write], [Terminal.reset], [Terminal.resize]); re-read via +/// [of] after such operations rather than retaining the previous value. +/// +/// Before any images are stored, Kitty graphics must be enabled on the +/// terminal by setting a non-zero [Terminal.kittyImageStorageLimit]. PNG +/// payloads additionally require a decoder installed via +/// [LibGhostty.setPngDecoder]. +/// +/// ```dart +/// final kitty = KittyGraphics.of(terminal); +/// if (kitty == null) return; +/// for (final placement in kitty.placements()) { +/// if (!placement.renderInfo.viewportVisible) continue; +/// final image = kitty.image(placement.imageId); +/// if (image == null) continue; +/// // draw `image.pixelData` cropped to `placement.renderInfo.source*` +/// // at grid cell (renderInfo.viewportCol, renderInfo.viewportRow). +/// } +/// ``` +@immutable +final class KittyGraphics { + final Terminal _terminal; + final LibGhosttyHandle _handle; + + const KittyGraphics._(this._handle, this._terminal); + + /// Storage-wide generation stamp for image content and placement changes. + /// + /// A changed value means the placement set or image data may be stale. If + /// the value is unchanged since a previous query, the placement set and all + /// image data are identical, so placement iteration and image staleness + /// checks can be skipped. + /// + /// Geometry can still change when this value is unchanged, for example when + /// scrolling or resizing moves placements through the viewport. Recompute + /// placement [KittyPlacementRenderInfo] on frames where terminal geometry or + /// scroll state + /// may have changed. + /// + /// Generation stamps are unique and monotonically increasing process-wide. + /// Zero means the storage has never been mutated and is empty. + int get generation => + bindings.kittyGraphics.kittyGraphicsGetGeneration(_handle); + + /// Looks up an image by its Kitty graphics [imageId]. + /// + /// Returns null when no image with that id is stored or when Kitty + /// graphics are disabled in the native library build. The returned + /// [KittyImage] handle is borrowed from the storage and is invalidated + /// by any mutating terminal call. Reacquire both this storage handle and the + /// image after a mutation. + KittyImage? image(int imageId) { + final handle = bindings.kittyGraphics.kittyGraphicsImage(_handle, imageId); + if (handle.value == 0) return null; + return KittyImage._(handle, _terminal); + } + + /// Snapshots every placement currently stored, optionally filtered by + /// z-layer. + /// + /// Each [KittyPlacement] captures placement metadata and resolved render + /// geometry at the time of this call. The snapshot data is stable + /// across subsequent terminal mutations, but the image referenced via + /// [KittyPlacement.imageId] is not; resolve it with [image] afresh when you + /// need pixel bytes after a mutation. + /// + /// Passing a [layer] other than [KittyPlacementLayer.all] installs a + /// z-layer filter on the iterator so placements outside the requested + /// layer are skipped. See [KittyPlacementLayer] for the bucket + /// boundaries. + /// + /// Throws [OutOfMemoryException] if the iterator allocation fails. + List placements({KittyPlacementLayer layer = .all}) { + final iterator = bindings.kittyGraphics.kittyGraphicsPlacementIteratorNew(); + try { + bindings.kittyGraphics.kittyGraphicsGetPlacements(_handle, iterator); + if (layer != KittyPlacementLayer.all) { + bindings.kittyGraphics.kittyGraphicsPlacementIteratorSetLayer( + iterator, + layer, + ); + } + final out = []; + while (bindings.kittyGraphics.kittyGraphicsPlacementNext(iterator)) { + out.add( + bindings.kittyGraphics.kittyGraphicsPlacementGet( + iterator, + _handle, + _terminal._terminalHandle, + ), + ); + } + return out; + } finally { + bindings.kittyGraphics.kittyGraphicsPlacementIteratorFree(iterator); + } + } + + /// Returns the Kitty graphics image storage for [terminal]'s active + /// screen, or null when Kitty graphics are disabled in the native + /// library build. + static KittyGraphics? of(Terminal terminal) { + final handle = bindings.kittyGraphics.kittyGraphicsGet( + terminal._terminalHandle, + ); + return handle.value == 0 ? null : KittyGraphics._(handle, terminal); + } +} + +/// A single image stored under the Kitty graphics protocol. +/// +/// Obtained via [KittyGraphics.image]. The handle is borrowed from the +/// terminal's image storage: every accessor reads live data and is +/// invalidated by any mutating terminal call ([Terminal.write], +/// [Terminal.reset], [Terminal.resize]). Read the values you need +/// immediately. Do not access a [KittyImage] after mutating the terminal; +/// reacquire it through [KittyGraphics.image] first. +/// +/// [pixelData] is the exception: it copies the bytes into a Dart-owned +/// [Uint8List], so the returned buffer remains valid after mutations. +@immutable +final class KittyImage { + // The image handle points into terminal-owned storage. This strong reference + // prevents terminal finalization while the borrowed handle is reachable. + // ignore: unused_field + final Terminal _owner; + + final LibGhosttyHandle _handle; + + const KittyImage._(this._handle, this._owner); + + /// Compression of [pixelData]. + KittyImageCompression get compression { + return bindings.kittyGraphics.kittyGraphicsImageGetCompression(_handle); + } + + /// Pixel format of [pixelData]. + KittyImageFormat get format { + return bindings.kittyGraphics.kittyGraphicsImageGetFormat(_handle); + } + + /// Generation stamp for this image's pixel contents. + /// + /// A changed value means cached texture data for this image id is stale, even + /// when dimensions, format, and byte length are unchanged. This catches + /// same-sized retransmissions that size heuristics cannot detect. + /// + /// Generation stamps are unique and monotonically increasing process-wide and + /// use the same sequence as [KittyGraphics.generation]. Stored images never + /// have generation zero, so zero can be used as an empty cache sentinel. + int get generation { + return bindings.kittyGraphics.kittyGraphicsImageGetGeneration(_handle); + } + + /// Image height in pixels. + int get height => bindings.kittyGraphics.kittyGraphicsImageGetHeight(_handle); + + /// Image id assigned by the Kitty graphics protocol. + int get id => bindings.kittyGraphics.kittyGraphicsImageGetId(_handle); + + /// Image number assigned by the protocol, or zero when unset. + int get number => bindings.kittyGraphics.kittyGraphicsImageGetNumber(_handle); + + /// Raw pixel bytes, copied into a Dart-owned buffer so the list remains + /// valid after subsequent terminal mutations. + /// + /// Stored images are already decoded and decompressed before they reach this + /// API. PNG payloads are decoded through the callback installed via + /// [LibGhostty.setPngDecoder] and exposed here as RGBA. + Uint8List get pixelData { + return bindings.kittyGraphics.kittyGraphicsImageGetPixelData(_handle); + } + + /// Image width in pixels. + int get width => bindings.kittyGraphics.kittyGraphicsImageGetWidth(_handle); +} diff --git a/packages/libghostty/lib/src/impl/terminal/render_state.dart b/packages/libghostty/lib/src/api/terminal/render_state.dart similarity index 77% rename from packages/libghostty/lib/src/impl/terminal/render_state.dart rename to packages/libghostty/lib/src/api/terminal/render_state.dart index 8900419a..3593421a 100644 --- a/packages/libghostty/lib/src/impl/terminal/render_state.dart +++ b/packages/libghostty/lib/src/api/terminal/render_state.dart @@ -40,6 +40,9 @@ enum DirtyState { /// without holding any lock on the terminal, enabling safe multi-threaded /// rendering. /// +/// Calling [dispose] more than once is safe. Every other member throws a +/// [StateError] after disposal. +/// /// ## Dirty Tracking /// /// Dirty state is tracked at two independent layers: a global [dirty] state @@ -69,10 +72,11 @@ enum DirtyState { /// } /// ``` final class RenderState { - static final _finalizer = Finalizer(bindings.renderStateFree); + static final _finalizer = Finalizer(bindings.render.renderStateFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; var _dirty = DirtyState.clean; var _cols = 0; var _rows = 0; @@ -81,43 +85,37 @@ final class RenderState { /// /// Call [update] before reading any viewport data. Throws /// [OutOfMemoryException] if the native allocation fails. - RenderState() : _handle = check(bindings.renderStateNew()) { + RenderState() : _handle = bindings.render.renderStateNew() { _finalizer.attach(this, _handle, detach: this); } /// Resolved color information from the last [update]: foreground, /// background, cursor color, and the full 256-color palette. - TerminalColors get colors => check(bindings.renderStateGetColors(_handle)); + TerminalColors get colors { + _ensureAlive(); + return bindings.render.renderStateGetColors(_handle); + } /// Viewport width in cells from the last [update]. - int get cols => _cols; + int get cols { + _ensureAlive(); + return _cols; + } /// Cursor state from the last [update]: position, visibility, blink, /// shape, and password input flag. If the cursor is outside the /// viewport, [Cursor.position] defaults to zero and [Cursor.wideTail] /// defaults to false. Cursor get cursor { - final raw = check(bindings.renderStateGetCursor(_handle)); - if (!raw.inViewport) { - return Cursor( - visible: raw.visible, - blinking: raw.blinking, - passwordInput: raw.passwordInput, - shape: raw.visualStyle, - ); - } - return Cursor( - position: Position(row: raw.viewportY, col: raw.viewportX), - visible: raw.visible, - blinking: raw.blinking, - wideTail: raw.viewportWideTail, - passwordInput: raw.passwordInput, - shape: raw.visualStyle, - ); + _ensureAlive(); + return bindings.render.renderStateGetCursor(_handle); } /// Global dirty state from the last [update]. - DirtyState get dirty => _dirty; + DirtyState get dirty { + _ensureAlive(); + return _dirty; + } /// Sets the global dirty state, typically to [DirtyState.clean] after a /// frame has been rendered. @@ -126,26 +124,27 @@ final class RenderState { /// independent; clear them via [RowIterator.dirty] during (or after) /// the render loop. set dirty(DirtyState value) { - checkCode( - bindings.renderStateSetDirty(_handle, switch (value) { - DirtyState.clean => RenderStateDirty.false$, - DirtyState.partial => RenderStateDirty.partial, - DirtyState.full => RenderStateDirty.full, - }), - ); + _ensureAlive(); + bindings.render.renderStateSetDirty(_handle, switch (value) { + DirtyState.clean => RenderStateDirty.false$, + DirtyState.partial => RenderStateDirty.partial, + DirtyState.full => RenderStateDirty.full, + }); _dirty = value; } /// Viewport height in cells from the last [update]. - int get rows => _rows; + int get rows { + _ensureAlive(); + return _rows; + } /// Releases the native render state handle. - /// - /// Must be called to free resources; the render state must not be used - /// afterward. void dispose() { + if (_disposed) return; + bindings.render.renderStateFree(_handle); _finalizer.detach(this); - bindings.renderStateFree(_handle); + _disposed = true; } /// Snapshots [terminal]'s state and consumes its dirty flag. @@ -158,16 +157,22 @@ final class RenderState { /// /// Any [RowIterator] or [CellIterator] previously bound to this render /// state must be rebound via [RowIterator.reset] / [CellIterator.reset] - /// before use. + /// before use. Do not access row or cell data through an iterator that was + /// bound before this update. /// /// Throws [OutOfMemoryException] if updating requires allocation and /// that allocation fails. DirtyState update(Terminal terminal) { - checkCode(bindings.renderStateUpdate(_handle, terminal._handle)); - final summary = check(bindings.renderStateGetSummary(_handle)); + _ensureAlive(); + bindings.render.renderStateUpdate(_handle, terminal._terminalHandle); + final summary = bindings.render.renderStateGetSummary(_handle); _dirty = DirtyState._fromRaw(summary.dirty); _cols = summary.cols; _rows = summary.rows; return _dirty; } + + void _ensureAlive() { + if (_disposed) throw StateError('RenderState has been disposed'); + } } diff --git a/packages/libghostty/lib/src/impl/terminal/row_iterator.dart b/packages/libghostty/lib/src/api/terminal/row_iterator.dart similarity index 64% rename from packages/libghostty/lib/src/impl/terminal/row_iterator.dart rename to packages/libghostty/lib/src/api/terminal/row_iterator.dart index f64f95f1..9e50d5d5 100644 --- a/packages/libghostty/lib/src/impl/terminal/row_iterator.dart +++ b/packages/libghostty/lib/src/api/terminal/row_iterator.dart @@ -13,6 +13,10 @@ typedef RowSelectionRange = ({int startCol, int endCol}); /// /// Bind to a [RenderState] with [reset]; call [reset] again after every /// [RenderState.update] so the iterator tracks the fresh snapshot. +/// Do not access the iterator after a render update until it has been rebound. +/// Access before a successful [next] throws [StateError]. +/// Calling [dispose] more than once is safe; every other member throws +/// [StateError] after disposal. /// /// ```dart /// final rows = RowIterator(); @@ -23,10 +27,14 @@ typedef RowSelectionRange = ({int startCol, int endCol}); /// } /// ``` final class RowIterator { - static final _finalizer = Finalizer(bindings.rowIteratorFree); + static final _finalizer = Finalizer(bindings.render.rowIteratorFree); - final int _handle; + final LibGhosttyHandle _handle; + var _disposed = false; + RenderState? _renderState; + var _positionGeneration = 0; + var _positioned = false; late RawRowSummary _rowSummary; var _rowSummaryValid = false; var _index = -1; @@ -35,17 +43,21 @@ final class RowIterator { /// /// Must be populated with [reset] before [next] is called. /// Throws [OutOfMemoryException] if the native allocation fails. - RowIterator() : _handle = check(bindings.rowIteratorNew()) { + RowIterator() : _handle = bindings.render.rowIteratorNew() { _finalizer.attach(this, _handle, detach: this); } /// Whether the current row has been modified since its dirty flag was /// last cleared. - bool get dirty => check(bindings.rowIteratorGetDirty(_handle)); + bool get dirty { + _ensurePositioned(); + return bindings.render.rowIteratorGetDirty(_handle); + } /// Sets or clears the dirty flag for the current row. set dirty(bool value) { - checkCode(bindings.rowIteratorSetDirty(_handle, dirty: value)); + _ensurePositioned(); + bindings.render.rowIteratorSetDirty(_handle, dirty: value); } /// Whether any cell in the current row contains a grapheme cluster @@ -76,16 +88,22 @@ final class RowIterator { /// Viewport-relative row index of the current row (zero-based). /// /// Undefined before the first successful [next] call. - int get index => _index; + int get index { + _ensurePositioned(); + return _index; + } /// Selected column range for the current row, or null when the row does /// not intersect the selection captured by the render state. /// /// The returned columns are row-local and inclusive. RowSelectionRange? get selection { - final (code, selection) = bindings.rowIteratorGetSelection(_handle); - if (code == .noValue) return null; - return check((code, selection)); + _ensurePositioned(); + final selection = bindings.render.rowIteratorGetSelection(_handle); + if (selection case (startCol: final start, endCol: final end)) { + return (startCol: start, endCol: end); + } + return null; } /// Semantic prompt state of the current row. @@ -108,22 +126,27 @@ final class RowIterator { } /// Releases the native iterator handle. - /// - /// Must be called to free resources; the iterator must not be used - /// afterward. void dispose() { + if (_disposed) return; + bindings.render.rowIteratorFree(_handle); _finalizer.detach(this); - bindings.rowIteratorFree(_handle); + _disposed = true; } /// Advances to the next row. Returns true when a row is available and /// the getter properties reflect it; returns false when the snapshot /// is exhausted. bool next() { - final hasNext = bindings.rowIteratorNext(_handle); + _ensureCurrent(); + final hasNext = bindings.render.rowIteratorNext(_handle); + _positionGeneration++; if (hasNext) { _rowSummaryValid = false; _index++; + _positioned = true; + } else { + _rowSummaryValid = false; + _positioned = false; } return hasNext; } @@ -134,18 +157,42 @@ final class RowIterator { /// Any [CellIterator] previously bound to this iterator must be rebound /// via [CellIterator.reset] before further use. void reset(RenderState renderState) { - checkCode(bindings.rowIteratorInit(_handle, renderState._handle)); + _ensureAlive(); + renderState._ensureAlive(); + bindings.render.rowIteratorInit(_handle, renderState._handle); + _renderState = renderState; + _positionGeneration++; _rowSummaryValid = false; _index = -1; + _positioned = false; + } + + void _ensureAlive() { + if (_disposed) throw StateError('RowIterator has been disposed'); + } + + void _ensureCurrent() { + _ensureAlive(); + if (_renderState == null) { + throw StateError('RowIterator has not been bound to a RenderState'); + } } void _ensureMetadata() { + _ensurePositioned(); if (!_rowSummaryValid) _refreshMetadata(); } + void _ensurePositioned() { + _ensureCurrent(); + if (!_positioned) { + throw StateError('RowIterator is not positioned on a row'); + } + } + void _refreshMetadata() { - final rawRow = check(bindings.rowIteratorGetRawRow(_handle)); - _rowSummary = check(bindings.rowGetSummary(rawRow)); + final rawRow = bindings.render.rowIteratorGetRawRow(_handle); + _rowSummary = bindings.render.rowGetSummary(rawRow); _rowSummaryValid = true; } } diff --git a/packages/libghostty/lib/src/impl/terminal/selection.dart b/packages/libghostty/lib/src/api/terminal/selection.dart similarity index 83% rename from packages/libghostty/lib/src/impl/terminal/selection.dart rename to packages/libghostty/lib/src/api/terminal/selection.dart index 3f409c0f..3ecc27a8 100644 --- a/packages/libghostty/lib/src/impl/terminal/selection.dart +++ b/packages/libghostty/lib/src/api/terminal/selection.dart @@ -31,8 +31,7 @@ final class Selection { /// /// Both refs must come from the same [Terminal]. The refs are values and are /// not consumed by the selection. The created selection follows normal - /// [GridRef] lifetime rules and must not be reused after a mutating terminal - /// call invalidates its endpoint snapshots. + /// [GridRef] lifetime rules. Do not reuse it after mutating the terminal. factory Selection.fromRefs({ required GridRef start, required GridRef end, @@ -59,7 +58,10 @@ final class Selection { /// The current endpoint order. SelectionOrder get order { final terminal = start._terminal; - return check(bindings.terminalSelectionOrder(terminal._handle, _raw)); + return bindings.selection.terminalSelectionOrder( + terminal._terminalHandle, + _raw, + ); } RawSelection get _raw { @@ -78,22 +80,22 @@ final class Selection { final terminal = start._terminal; return Selection._fromRaw( terminal, - check( - bindings.terminalSelectionAdjust(terminal._handle, _raw, adjustment), - )!, + bindings.selection.terminalSelectionAdjust( + terminal._terminalHandle, + _raw, + adjustment, + ), ); } /// Whether this selection includes [position]. bool contains(Position position, {PointTag pointTag = .active}) { final terminal = start._terminal; - return check( - bindings.terminalSelectionContains( - terminal._handle, - _raw, - pointTag, - position, - ), + return bindings.selection.terminalSelectionContains( + terminal._terminalHandle, + _raw, + pointTag, + position, ); } @@ -101,8 +103,10 @@ final class Selection { bool equal(Selection other) { final terminal = start._terminal; _checkSameTerminal(other, terminal); - return check( - bindings.terminalSelectionEqual(terminal._handle, _raw, other._raw), + return bindings.selection.terminalSelectionEqual( + terminal._terminalHandle, + _raw, + other._raw, ); } @@ -113,15 +117,13 @@ final class Selection { bool trim = false, }) { final terminal = start._terminal; - final (code, text) = bindings.terminalSelectionFormat( - terminal._handle, + return bindings.selection.terminalSelectionFormat( + terminal._terminalHandle, format, unwrap: unwrap, trim: trim, selection: _raw, - ); - checkCode(code); - return text; + )!; } /// Returns this selection with endpoints ordered as [desired]. @@ -129,9 +131,11 @@ final class Selection { final terminal = start._terminal; return Selection._fromRaw( terminal, - check( - bindings.terminalSelectionOrdered(terminal._handle, _raw, desired), - )!, + bindings.selection.terminalSelectionOrdered( + terminal._terminalHandle, + _raw, + desired, + ), ); } diff --git a/packages/libghostty/lib/src/api/terminal/selection_gesture.dart b/packages/libghostty/lib/src/api/terminal/selection_gesture.dart new file mode 100644 index 00000000..ef22265d --- /dev/null +++ b/packages/libghostty/lib/src/api/terminal/selection_gesture.dart @@ -0,0 +1,378 @@ +part of 'terminal.dart'; + +/// Mutable state machine for terminal text selection gestures. +/// +/// The gesture converts reusable [SelectionGestureEvent] values into selection +/// snapshots. Returned selections are not installed automatically. The +/// creating terminal must outlive this gesture for [state], [apply], and +/// [reset]. +/// +/// Members other than [dispose] throw [StateError] after this gesture or its +/// creating terminal has been disposed. Invalid event data throws +/// [InvalidValueException]. +final class SelectionGesture { + static final _finalizer = Finalizer(( + ({LibGhosttyHandle handle, WeakReference terminal}) token, + ) { + final terminal = token.terminal.target; + bindings.selection.selectionGestureFree( + token.handle, + terminal?._handleOrNull ?? const LibGhosttyHandle.fromAddress(0), + ); + }); + + final LibGhosttyHandle _handle; + final Terminal _terminal; + var _disposed = false; + + /// Creates a gesture state machine bound to [terminal]. + SelectionGesture(Terminal terminal) + : _handle = bindings.selection.selectionGestureNew(), + _terminal = terminal { + _finalizer.attach(this, ( + handle: _handle, + terminal: WeakReference(_terminal), + ), detach: this); + } + + /// Current readable gesture state. + SelectionGestureState get state { + final handle = _requireHandle(); + final raw = bindings.selection.selectionGestureGetState( + handle, + _terminalHandle, + ); + return SelectionGestureState( + clickCount: raw.clickCount, + dragged: raw.dragged, + autoscroll: raw.autoscroll, + behavior: raw.behavior, + anchor: raw.anchor == null ? null : ._fromValue(_terminal, raw.anchor!), + ); + } + + LibGhosttyHandle get _terminalHandle { + final handle = _terminal._handleOrNull; + if (handle == null) { + throw StateError('SelectionGesture terminal has been disposed'); + } + return handle; + } + + /// Applies [event] and returns the produced selection snapshot, if any. + Selection? apply(SelectionGestureEvent event) { + final handle = _requireHandle(); + event._ensureRefFor(_terminal); + final raw = bindings.selection.selectionGestureEvent( + handle, + _terminalHandle, + event._requireHandle(), + ); + return raw == null ? null : Selection._fromRaw(_terminal, raw); + } + + /// Releases the native gesture handle. + /// + /// Calling [dispose] more than once is safe. The gesture must not be used + /// afterward. It is safe to dispose after the creating terminal has been + /// disposed. + void dispose() { + if (_disposed) return; + bindings.selection.selectionGestureFree( + _handle, + _terminal._handleOrNull ?? const LibGhosttyHandle.fromAddress(0), + ); + _finalizer.detach(this); + _disposed = true; + } + + /// Clears active gesture state while keeping this gesture reusable. + void reset() { + final handle = _requireHandle(); + bindings.selection.selectionGestureReset(handle, _terminalHandle); + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) throw StateError('SelectionGesture has been disposed'); + return _handle; + } +} + +/// Selection behavior table for single-, double-, and triple-click gestures. +@immutable +final class SelectionGestureBehaviors { + /// Standard terminal selection behavior: cell, word, line. + static const standard = SelectionGestureBehaviors( + singleClick: .cell, + doubleClick: .word, + tripleClick: .line, + ); + + /// Behavior for single-click selection gestures. + final SelectionGestureBehavior singleClick; + + /// Behavior for double-click selection gestures. + final SelectionGestureBehavior doubleClick; + + /// Behavior for triple-click selection gestures. + final SelectionGestureBehavior tripleClick; + + /// Creates a behavior table for gesture press events. + const SelectionGestureBehaviors({ + required this.singleClick, + required this.doubleClick, + required this.tripleClick, + }); +} + +/// Reusable event data for a selection gesture operation. +/// +/// The event kind is fixed at construction time. Set options before applying +/// the event with [SelectionGesture.apply]. +/// +/// Members other than [dispose] throw [StateError] after disposal. Invalid +/// option values or options unsupported for this event type throw +/// [InvalidValueException]. +final class SelectionGestureEvent { + static final _finalizer = Finalizer( + bindings.selection.selectionGestureEventFree, + ); + + final LibGhosttyHandle _handle; + var _disposed = false; + GridRef? _ref; + + /// Creates an autoscroll tick event. + SelectionGestureEvent.autoscrollTick() : this._(.autoscrollTick); + + /// Creates a deep-press event. + SelectionGestureEvent.deepPress() : this._(.deepPress); + + /// Creates a drag event. + SelectionGestureEvent.drag() : this._(.drag); + + /// Creates a press event. + SelectionGestureEvent.press() : this._(.press); + + /// Creates a release event. + SelectionGestureEvent.release() : this._(.release); + + SelectionGestureEvent._(SelectionGestureEventType type) + : _handle = bindings.selection.selectionGestureEventNew(type) { + _finalizer.attach(this, _handle, detach: this); + } + + /// Clears the surface-space pointer position. + void clearPosition() => _clear(.position); + + /// Releases the event handle. Calling [dispose] more than once is safe. + void dispose() { + if (_disposed) return; + bindings.selection.selectionGestureEventFree(_handle); + _finalizer.detach(this); + _ref = null; + _disposed = true; + } + + /// Sets the behavior table for press events, or clears it to restore + /// libghostty's default cell, word, and line behaviors. + void setBehaviors(SelectionGestureBehaviors? behaviors) { + if (behaviors == null) { + _clear(.behaviors); + return; + } + bindings.selection.selectionGestureEventSetBehaviors( + _requireHandle(), + behaviors.singleClick, + behaviors.doubleClick, + behaviors.tripleClick, + ); + } + + /// Sets drag display geometry, or clears it. + void setGeometry(SelectionGestureGeometry? geometry) { + if (geometry == null) { + _clear(.geometry); + return; + } + bindings.selection.selectionGestureEventSetGeometry( + _requireHandle(), + columns: geometry.columns, + cellWidth: geometry.cellWidth, + paddingLeft: geometry.paddingLeft, + screenHeight: geometry.screenHeight, + ); + } + + /// Sets the surface-space pointer position. + void setPosition(double x, double y) { + bindings.selection.selectionGestureEventSetPosition(_requireHandle(), x, y); + } + + /// Sets whether drag/autoscroll events produce a rectangular selection, or + /// clears the option to restore its initialized default. + void setRectangle({required bool? value}) { + if (value == null) { + _clear(.rectangle); + return; + } + bindings.selection.selectionGestureEventSetRectangle( + _requireHandle(), + value: value, + ); + } + + /// Sets or clears the grid reference under the pointer. + /// + /// The reference must remain valid until [SelectionGesture.apply] and must + /// belong to that gesture's terminal. A reference from another terminal + /// throws [ArgumentError]. Do not mutate the terminal between assigning the + /// reference and applying the event. + void setRef(GridRef? ref) { + if (ref == null) { + _clear(.ref); + _ref = null; + return; + } + final handle = _requireHandle(); + bindings.selection.selectionGestureEventSetRef(handle, ref._value); + _ref = ref; + } + + /// Sets the maximum repeat-click distance in pixels, or clears it. + void setRepeatDistance(double? value) { + if (value == null) { + _clear(.repeatDistance); + return; + } + bindings.selection.selectionGestureEventSetRepeatDistance( + _requireHandle(), + value, + ); + } + + /// Sets the maximum interval between repeat clicks in nanoseconds, or clears + /// it. + void setRepeatIntervalNs(int? value) { + if (value == null) { + _clear(.repeatIntervalNs); + return; + } + bindings.selection.selectionGestureEventSetRepeatIntervalNs( + _requireHandle(), + value, + ); + } + + /// Sets the monotonic event time in nanoseconds, or clears it. + void setTimeNs(int? value) { + if (value == null) { + _clear(.timeNs); + return; + } + bindings.selection.selectionGestureEventSetTimeNs(_requireHandle(), value); + } + + /// Sets the viewport coordinate for an autoscroll tick, or clears it. + void setViewport(Position? position) { + if (position == null) { + _clear(.viewport); + return; + } + bindings.selection.selectionGestureEventSetViewport( + _requireHandle(), + position: position, + ); + } + + /// Sets word-boundary codepoints. The codepoints are copied into + /// event-owned storage. An empty list is an explicit empty boundary set; + /// null restores libghostty's default boundaries. + void setWordBoundaryCodepoints(List? codepoints) { + if (codepoints == null) { + _clear(.wordBoundaryCodepoints); + return; + } + bindings.selection.selectionGestureEventSetWordBoundaryCodepoints( + _requireHandle(), + codepoints, + ); + } + + void _clear(SelectionGestureEventOption option) { + bindings.selection.selectionGestureEventClear(_requireHandle(), option); + } + + void _ensureRefFor(Terminal terminal) { + _requireHandle(); + final ref = _ref; + if (ref == null) return; + if (!identical(ref._terminal, terminal)) { + throw ArgumentError.value( + ref, + 'event', + 'must belong to gesture terminal', + ); + } + } + + LibGhosttyHandle _requireHandle() { + if (_disposed) { + throw StateError('SelectionGestureEvent has been disposed'); + } + return _handle; + } +} + +/// Display geometry used to interpret drag and autoscroll gesture events. +@immutable +final class SelectionGestureGeometry { + /// Number of rendered terminal columns. Must be non-zero. + final int columns; + + /// Width of one terminal cell in surface pixels. Must be non-zero. + final int cellWidth; + + /// Left padding before the terminal grid begins in surface pixels. + final int paddingLeft; + + /// Height of the rendered terminal surface in surface pixels. Must be + /// non-zero. + final int screenHeight; + + /// Creates display geometry for drag and autoscroll events. + const SelectionGestureGeometry({ + required this.columns, + required this.cellWidth, + required this.paddingLeft, + required this.screenHeight, + }); +} + +/// Current readable state for a selection gesture. +@immutable +final class SelectionGestureState { + /// Current click count. Zero means inactive. + final int clickCount; + + /// Whether the current or last left-click gesture dragged. + final bool dragged; + + /// Current autoscroll request. + final SelectionGestureAutoscroll autoscroll; + + /// Current gesture behavior. + final SelectionGestureBehavior behavior; + + /// Current left-click anchor, or null when there is no active anchor. + final GridRef? anchor; + + /// Creates a selection gesture state snapshot. + const SelectionGestureState({ + required this.clickCount, + required this.dragged, + required this.autoscroll, + required this.behavior, + required this.anchor, + }); +} diff --git a/packages/libghostty/lib/src/impl/terminal/terminal.dart b/packages/libghostty/lib/src/api/terminal/terminal.dart similarity index 69% rename from packages/libghostty/lib/src/impl/terminal/terminal.dart rename to packages/libghostty/lib/src/api/terminal/terminal.dart index 77e4ce25..f83fc85e 100644 --- a/packages/libghostty/lib/src/impl/terminal/terminal.dart +++ b/packages/libghostty/lib/src/api/terminal/terminal.dart @@ -3,8 +3,10 @@ import 'dart:typed_data'; import 'package:meta/meta.dart'; import '../../bindings/bindings.dart'; -import '../../ffi/libghostty_enums.g.dart'; +import '../../bindings/types.dart'; +import '../../generated/libghostty_enums.g.dart'; import '../../listenable.dart'; +import '../../types/types.dart'; import '../key/kitty_key_flags.dart'; import '../key/mods.dart'; import 'terminal_mode.dart'; @@ -14,7 +16,6 @@ part '../key/key_event.dart'; part '../mouse/mouse_encoder.dart'; part '../mouse/mouse_event.dart'; part 'cell_iterator.dart'; -part 'cursor.dart'; part 'formatter.dart'; part 'grid_ref.dart'; part 'kitty_graphics.dart'; @@ -53,11 +54,13 @@ part 'tracked_grid_ref.dart'; /// setters ([onWritePty], [onBell], [onTitleChanged], etc.). Set to null to /// disable. /// -/// Callbacks run synchronously. Callers **must not** call [write] from within a -/// callback (no reentrancy), and callbacks should avoid blocking or expensive -/// operations since they block further I/O processing. Callback exceptions do -/// not interrupt terminal processing. After the initiating operation finishes, -/// the first exception is rethrown with its original stack trace. +/// Callbacks run synchronously. An effect invoked while [write] is processing +/// VT input must not call [write] for the same terminal. The [onWritePty] +/// callback emitted by an in-band [resize] report may call [write]. Callbacks +/// should avoid blocking or expensive operations since they block further I/O +/// processing. Callback exceptions do not interrupt terminal processing. After +/// the initiating operation finishes, the first exception is rethrown with its +/// original stack trace. /// /// Title query responses are disabled by default. Enable them with /// [setTitleReports] in addition to registering [onWritePty]. @@ -74,6 +77,9 @@ part 'tracked_grid_ref.dart'; /// The default-only getters ([foregroundDefault], [backgroundDefault], /// [cursorColorDefault], [paletteDefault]) ignore OSC overrides. /// +/// Calling [dispose] more than once is safe. Every other member throws a +/// [StateError] after disposal. +/// /// ```dart /// final terminal = Terminal(cols: 80, rows: 24); /// @@ -86,12 +92,9 @@ part 'tracked_grid_ref.dart'; /// terminal.dispose(); /// ``` final class Terminal with Listenable { - static final _finalizer = Finalizer((handle) { - bindings.terminalDisposeCallbacks(handle); - bindings.terminalFree(handle); - }); + static final _finalizer = Finalizer(bindings.terminal.terminalFree); - final int _handle; + final LibGhosttyHandle _handle; bool _disposed; /// Creates a terminal with the given grid dimensions. @@ -104,7 +107,7 @@ final class Terminal with Listenable { /// final terminal = Terminal(cols: 80, rows: 24); /// ``` Terminal({required int cols, required int rows}) - : _handle = check(bindings.terminalNew(cols, rows)), + : _handle = bindings.terminal.terminalNew(cols, rows), _disposed = false { _finalizer.attach(this, _handle, detach: this); } @@ -114,7 +117,7 @@ final class Terminal with Listenable { /// Programs switch screens via DEC private mode 1049 (e.g. when entering /// full-screen editors like vim). TerminalScreen get activeScreen { - return check(bindings.terminalGetActiveScreen(_handle)); + return bindings.terminal.terminalGetActiveScreen(_terminalHandle); } /// Effective background color (OSC override if active, otherwise default). @@ -122,7 +125,7 @@ final class Terminal with Listenable { /// Returns null if no color is configured (neither a default nor an OSC /// override). RgbColor? get background { - return _optionalValue(bindings.terminalGetColorBackground(_handle)); + return bindings.terminal.terminalGetColorBackground(_terminalHandle); } /// Sets the default background color, or clears it if null. @@ -130,14 +133,14 @@ final class Terminal with Listenable { /// This sets the embedder default. Programs running in the terminal can /// still override it via OSC 11. set background(RgbColor? color) { - checkCode(bindings.terminalSetColorBackground(_handle, color)); + bindings.terminal.terminalSetColorBackground(_terminalHandle, color); } /// Default background color, ignoring any OSC override. /// /// Returns null if no default has been configured. RgbColor? get backgroundDefault { - return _optionalValue(bindings.terminalGetColorBackgroundDefault(_handle)); + return bindings.terminal.terminalGetColorBackgroundDefault(_terminalHandle); } /// Opaque token that changes when scrollback compression may have new work. @@ -153,7 +156,7 @@ final class Terminal with Listenable { /// if (terminal.compressionActivity != previous) scheduleCompression(); /// ``` int get compressionActivity { - return check(bindings.terminalCompressionActivity(_handle)); + return bindings.terminal.terminalCompressionActivity(_terminalHandle); } /// Replay-safe bytes for the terminal's unfinished VT or UTF-8 input. @@ -167,7 +170,7 @@ final class Terminal with Listenable { /// [OutOfMemoryException] if the bytes cannot be allocated. Access this /// property serially with [write] and other terminal operations. Uint8List get continuation { - return check(bindings.terminalContinuationGet(_handle)); + return bindings.terminal.terminalContinuationGet(_terminalHandle); } /// Maximum number of unfinished VT or UTF-8 bytes retained for @@ -176,7 +179,7 @@ final class Terminal with Listenable { /// A value of zero disables continuation tracking. Tracking must be enabled /// before the input that produces unfinished parser state is written. int get continuationMaxBytes { - return check(bindings.terminalGetContinuationMaxBytes(_handle)); + return bindings.terminal.terminalGetContinuationMaxBytes(_terminalHandle); } /// Sets the maximum number of unfinished VT or UTF-8 bytes retained for @@ -187,41 +190,45 @@ final class Terminal with Listenable { /// unfinished input has already been written does not recover it. set continuationMaxBytes(int value) { RangeError.checkNotNegative(value, 'value'); - checkCode(bindings.terminalSetContinuationMaxBytes(_handle, value)); + bindings.terminal.terminalSetContinuationMaxBytes(_terminalHandle, value); } /// Effective cursor color (OSC override if active, otherwise default). /// /// Returns null if no color is configured. RgbColor? get cursorColor { - return _optionalValue(bindings.terminalGetColorCursor(_handle)); + return bindings.terminal.terminalGetColorCursor(_terminalHandle); } /// Sets the default cursor color, or clears it if null. /// /// Programs running in the terminal can override this via OSC 12. set cursorColor(RgbColor? color) { - checkCode(bindings.terminalSetColorCursor(_handle, color)); + bindings.terminal.terminalSetColorCursor(_terminalHandle, color); } /// Default cursor color, ignoring any OSC override. /// /// Returns null if no default has been configured. RgbColor? get cursorColorDefault { - return _optionalValue(bindings.terminalGetColorCursorDefault(_handle)); + return bindings.terminal.terminalGetColorCursorDefault(_terminalHandle); } /// The cursor's current SGR style (applied to newly printed characters). - Style get cursorStyle => check(bindings.terminalGetCursorStyle(_handle)); + Style get cursorStyle => + bindings.terminal.terminalGetCursorStyle(_terminalHandle); /// Sets whether DECSCUSR reset (CSI 0 q) restores a blinking cursor. set defaultCursorBlink(bool? value) { - checkCode(bindings.terminalSetDefaultCursorBlink(_handle, blinking: value)); + bindings.terminal.terminalSetDefaultCursorBlink( + _terminalHandle, + blinking: value, + ); } /// Sets the cursor shape restored by DECSCUSR reset (CSI 0 q). - set defaultCursorShape(CursorShape? value) { - checkCode(bindings.terminalSetDefaultCursorShape(_handle, value)); + set defaultCursorShape(TerminalCursorShape? value) { + bindings.terminal.terminalSetDefaultCursorShape(_terminalHandle, value); } /// Effective foreground color (OSC override if active, otherwise default). @@ -229,21 +236,21 @@ final class Terminal with Listenable { /// Returns null if no color is configured (neither a default nor an OSC /// override). RgbColor? get foreground { - return _optionalValue(bindings.terminalGetColorForeground(_handle)); + return bindings.terminal.terminalGetColorForeground(_terminalHandle); } /// Sets the default foreground color, or clears it if null. /// /// Programs running in the terminal can override this via OSC 10. set foreground(RgbColor? color) { - checkCode(bindings.terminalSetColorForeground(_handle, color)); + bindings.terminal.terminalSetColorForeground(_terminalHandle, color); } /// Default foreground color, ignoring any OSC override. /// /// Returns null if no default has been configured. RgbColor? get foregroundDefault { - return _optionalValue(bindings.terminalGetColorForegroundDefault(_handle)); + return bindings.terminal.terminalGetColorForegroundDefault(_terminalHandle); } /// Current terminal dimensions in cells and pixels. @@ -254,7 +261,8 @@ final class Terminal with Listenable { /// final geometry = terminal.geometry; /// final cellWidth = geometry.widthPx ~/ geometry.cols; /// ``` - TerminalGeometry get geometry => check(bindings.terminalGetGeometry(_handle)); + TerminalGeometry get geometry => + bindings.terminal.terminalGetGeometry(_terminalHandle); /// Whether VT processing encountered a non-gracefully handled error. /// @@ -268,34 +276,33 @@ final class Terminal with Listenable { /// } /// ``` bool get hasVtProcessingError { - return check(bindings.terminalGetVtProcessingError(_handle)); + return bindings.terminal.terminalGetVtProcessingError(_terminalHandle); } /// Total terminal height in pixels (rows * cell height). - int get heightPx => check(bindings.terminalGetHeightPx(_handle)); + int get heightPx => bindings.terminal.terminalGetHeightPx(_terminalHandle); /// Whether the file medium is enabled for Kitty image loading. /// Returns null when Kitty graphics are not compiled in. bool? get isKittyFileMedium { - final (code, value) = bindings.terminalGetKittyImageMediumFile(_handle); - return code == .noValue ? null : check((code, value)); + return bindings.terminal.terminalGetKittyImageMediumFile(_terminalHandle); } /// Whether the shared memory medium is enabled for Kitty image loading. /// Returns null when Kitty graphics are not compiled in. bool? get isKittySharedMemMedium { - final (code, value) = bindings.terminalGetKittyImageMediumSharedMem( - _handle, + return bindings.terminal.terminalGetKittyImageMediumSharedMem( + _terminalHandle, ); - return code == .noValue ? null : check((code, value)); } /// Whether any mouse tracking mode is currently active. - bool get isMouseTracking => check(bindings.terminalGetMouseTracking(_handle)); + bool get isMouseTracking => + bindings.terminal.terminalGetMouseTracking(_terminalHandle); /// Whether the viewport is at the active terminal area instead of scrollback. bool get isViewportActive { - return check(bindings.terminalGetViewportActive(_handle)); + return bindings.terminal.terminalGetViewportActive(_terminalHandle); } /// Kitty image storage limit in bytes for the active screen. @@ -303,14 +310,13 @@ final class Terminal with Listenable { /// Zero means the Kitty graphics protocol is disabled. Returns null when /// Kitty graphics support is not compiled into the library. int? get kittyImageStorageLimit { - final (code, value) = bindings.terminalGetKittyImageStorageLimit(_handle); - return code == .noValue ? null : check((code, value)); + return bindings.terminal.terminalGetKittyImageStorageLimit(_terminalHandle); } /// Sets the Kitty image storage limit in bytes. Zero or null disables /// the Kitty graphics protocol entirely. set kittyImageStorageLimit(int? value) { - checkCode(bindings.terminalSetKittyImageStorageLimit(_handle, value)); + bindings.terminal.terminalSetKittyImageStorageLimit(_terminalHandle, value); } /// Current Kitty keyboard protocol flags. @@ -319,7 +325,7 @@ final class Terminal with Listenable { /// Kitty keyboard protocol. Use [KeyEncoder] to encode key events according /// to these flags. KittyKeyFlags get kittyKeyboardFlags => KittyKeyFlags.fromValue( - check(bindings.terminalGetKittyKeyboardFlags(_handle)), + bindings.terminal.terminalGetKittyKeyboardFlags(_terminalHandle), ); /// Directory allowed for Kitty image loading through temporary files. @@ -331,8 +337,9 @@ final class Terminal with Listenable { /// final directory = terminal.kittyTempFileDirectory; /// ``` String? get kittyTempFileDirectory { - final (code, value) = bindings.terminalGetKittyImageMediumTempFile(_handle); - return code == .noValue ? null : check((code, value)); + return bindings.terminal.terminalGetKittyImageMediumTempFile( + _terminalHandle, + ); } /// Active mouse tracking mode derived from the current terminal modes. @@ -351,14 +358,17 @@ final class Terminal with Listenable { /// Registers a callback for BEL character (0x07). /// /// Fires synchronously during [write]. Set to null to ignore bell events. - set onBell(VoidCallback? value) => bindings.terminalSetOnBell(_handle, value); + set onBell(VoidCallback? value) { + bindings.terminal.terminalSetOnBell(_terminalHandle, value); + } /// Registers a callback for clipboard writes requested by terminal content. /// - /// OSC 52 and iTerm2 Copy requests are decoded into protocol-neutral, - /// binary-safe [ClipboardWrite] values. Return the result of committing the - /// complete request. Fires synchronously during [write]. Set to null to - /// ignore clipboard writes. + /// OSC 52 and iTerm2 OSC 1337 Copy requests are decoded into + /// protocol-neutral, binary-safe [ClipboardWrite] values. OSC 52 clipboard + /// read requests are ignored and never reach this callback. Return the + /// result of committing the complete request. Fires synchronously during + /// [write]. Set to null to ignore clipboard writes. /// /// ```dart /// terminal.onClipboardWrite = (request) { @@ -368,7 +378,7 @@ final class Terminal with Listenable { /// }; /// ``` set onClipboardWrite(ClipboardWriteCallback? value) { - bindings.terminalSetOnClipboardWrite(_handle, value); + bindings.terminal.terminalSetOnClipboardWrite(_terminalHandle, value); } /// Registers a callback for color scheme queries (CSI ? 996 n). @@ -376,7 +386,7 @@ final class Terminal with Listenable { /// Return the current [ColorScheme], or null to silently ignore the query. /// Fires synchronously during [write]. set onColorScheme(ValueGetter? value) { - bindings.terminalSetOnColorScheme(_handle, value); + bindings.terminal.terminalSetOnColorScheme(_terminalHandle, value); } /// Registers a callback for OSC 9 and OSC 777 desktop notifications. @@ -385,7 +395,7 @@ final class Terminal with Listenable { /// how to display them. Fires synchronously during [write]. Set to null to /// ignore notifications. set onDesktopNotification(DesktopNotificationCallback? value) { - bindings.terminalSetOnDesktopNotification(_handle, value); + bindings.terminal.terminalSetOnDesktopNotification(_terminalHandle, value); } /// Registers a callback for device attributes queries (CSI c / > c / = c). @@ -393,7 +403,7 @@ final class Terminal with Listenable { /// Return a [DeviceAttributesResponse], or null to silently ignore the query. /// Fires synchronously during [write]. set onDeviceAttributes(ValueGetter? value) { - bindings.terminalSetOnDeviceAttributes(_handle, value); + bindings.terminal.terminalSetOnDeviceAttributes(_terminalHandle, value); } /// Registers a callback for ENQ character (0x05). @@ -401,22 +411,23 @@ final class Terminal with Listenable { /// Return the response bytes to write back to the PTY. Return an empty list /// to send no response. Fires synchronously during [write]. set onEnquiry(ValueGetter? value) { - bindings.terminalSetOnEnquiry(_handle, value); + bindings.terminal.terminalSetOnEnquiry(_terminalHandle, value); } /// Registers a callback for OSC 9;4 progress reports. /// /// Fires synchronously during [write]. Set to null to ignore reports. set onProgressReport(TerminalProgressCallback? value) { - bindings.terminalSetOnProgressReport(_handle, value); + bindings.terminal.terminalSetOnProgressReport(_terminalHandle, value); } /// Registers a callback for working-directory changes via OSC 7/9/1337. /// - /// Query the new [pwd] after the callback returns. Fires synchronously - /// during [write]. + /// Read the new [pwd] inside the callback. OSC 7 values remain raw URIs; + /// OSC 9 and OSC 1337 values are typically paths. Fires synchronously during + /// [write]. set onPwdChanged(VoidCallback? value) { - bindings.terminalSetOnPwdChanged(_handle, value); + bindings.terminal.terminalSetOnPwdChanged(_terminalHandle, value); } /// Registers a callback for XTWINOPS size queries (CSI 14/16/18 t). @@ -424,15 +435,15 @@ final class Terminal with Listenable { /// Return a [TerminalSizeInfo] with the current geometry, or null to /// silently ignore the query. Fires synchronously during [write]. set onSize(ValueGetter? value) { - bindings.terminalSetOnSize(_handle, value); + bindings.terminal.terminalSetOnSize(_terminalHandle, value); } /// Registers a callback for title changes via OSC 0 or OSC 2. /// - /// Query the new [title] after the callback returns. Fires synchronously - /// during [write]. + /// Read the new [title] inside the callback. Fires synchronously during + /// [write]. set onTitleChanged(VoidCallback? value) { - bindings.terminalSetOnTitleChanged(_handle, value); + bindings.terminal.terminalSetOnTitleChanged(_terminalHandle, value); } /// Registers a callback for PTY write-back data. @@ -443,7 +454,7 @@ final class Terminal with Listenable { /// responses also use this callback when [setTitleReports] is enabled. Fires /// synchronously during [write]. set onWritePty(ValueSetter? value) { - bindings.terminalSetOnWritePty(_handle, value); + bindings.terminal.terminalSetOnWritePty(_terminalHandle, value); } /// Registers a callback for XTVERSION queries (CSI > q). @@ -452,17 +463,15 @@ final class Terminal with Listenable { /// string to use the default "libghostty" identifier. Fires synchronously /// during [write]. set onXtversion(ValueGetter? value) { - bindings.terminalSetOnXtversion(_handle, value); + bindings.terminal.terminalSetOnXtversion(_terminalHandle, value); } /// Current 256-color palette with any active OSC 4 overrides applied. /// /// Always returns a 256-element list (the built-in default palette is used /// as a baseline). - /// - /// Throws [LibGhosttyException] if the terminal is in an invalid state. List get palette { - return check(bindings.terminalGetColorPalette(_handle)); + return bindings.terminal.terminalGetColorPalette(_terminalHandle); } /// Sets the default 256-color palette, or resets to built-in defaults if @@ -471,33 +480,36 @@ final class Terminal with Listenable { /// Only updates indices that have not been overridden by OSC 4. Per-index /// OSC overrides are preserved. /// - /// Throws [LibGhosttyException] if the palette cannot be set. + /// Throws [ArgumentError] when [colors] is non-null and does not contain + /// exactly 256 entries. set palette(List? colors) { - checkCode(bindings.terminalSetColorPalette(_handle, colors)); + bindings.terminal.terminalSetColorPalette(_terminalHandle, colors); } /// Default 256-color palette, ignoring any OSC 4 overrides. - /// - /// Throws [LibGhosttyException] if the terminal is in an invalid state. List get paletteDefault { - return check(bindings.terminalGetColorPaletteDefault(_handle)); + return bindings.terminal.terminalGetColorPaletteDefault(_terminalHandle); } /// Current working directory as reported by OSC 7. /// - /// The returned value is borrowed from the terminal. Read it immediately - /// after [write] or [reset]; it may change on the next call to either. - String get pwd => check(bindings.terminalGetPwd(_handle)); + /// The terminal stores the bytes reported by OSC 7, OSC 9, or OSC 1337 + /// without parsing them. OSC 7 values are therefore raw URIs, while the + /// other forms are typically paths. The returned string is a Dart-owned + /// snapshot and remains valid after subsequent terminal operations. + String get pwd => bindings.terminal.terminalGetPwd(_terminalHandle); /// Sets the working directory, or clears it if null. - set pwd(String? value) => checkCode(bindings.terminalSetPwd(_handle, value)); + set pwd(String? value) { + bindings.terminal.terminalSetPwd(_terminalHandle, value); + } /// Maximum bytes retained for scrollback, or null when unlimited. /// /// This limit and [scrollbackMaxLines] apply together. Ghostty prunes when /// either limit is reached, at page granularity. int? get scrollbackMaxBytes { - return _optionalValue(bindings.terminalGetScrollbackMaxBytes(_handle)); + return bindings.terminal.terminalGetScrollbackMaxBytes(_terminalHandle); } /// Sets the maximum bytes retained for scrollback. @@ -505,7 +517,7 @@ final class Terminal with Listenable { /// Set to null for no byte limit or zero to clear retained history and /// disable scrollback by bytes. set scrollbackMaxBytes(int? value) { - checkCode(bindings.terminalSetScrollbackMaxBytes(_handle, value)); + bindings.terminal.terminalSetScrollbackMaxBytes(_terminalHandle, value); } /// Maximum physical lines retained for scrollback, or null when unlimited. @@ -513,7 +525,7 @@ final class Terminal with Listenable { /// This limit and [scrollbackMaxBytes] apply together. Ghostty prunes when /// either limit is reached, at page granularity. int? get scrollbackMaxLines { - return _optionalValue(bindings.terminalGetScrollbackMaxLines(_handle)); + return bindings.terminal.terminalGetScrollbackMaxLines(_terminalHandle); } /// Sets the maximum physical lines retained for scrollback. @@ -521,11 +533,12 @@ final class Terminal with Listenable { /// Set to null for no line limit or zero to clear retained history and /// disable scrollback by lines. set scrollbackMaxLines(int? value) { - checkCode(bindings.terminalSetScrollbackMaxLines(_handle, value)); + bindings.terminal.terminalSetScrollbackMaxLines(_terminalHandle, value); } /// Number of rows in the scrollback buffer (excluding the active grid). - int get scrollbackRows => check(bindings.terminalGetScrollbackRows(_handle)); + int get scrollbackRows => + bindings.terminal.terminalGetScrollbackRows(_terminalHandle); /// Scrollbar position and dimensions for rendering a scrollbar widget. /// @@ -536,17 +549,16 @@ final class Terminal with Listenable { /// /// There is no scroll-state notification. Callers building scrollbars should /// poll this once per frame or per write batch and diff the result. - Scrollbar get scrollbar => check(bindings.terminalGetScrollbar(_handle)); + Scrollbar get scrollbar => + bindings.terminal.terminalGetScrollbar(_terminalHandle); /// Active selection on the terminal screen, or null when none is active. /// /// Getting returns an untracked snapshot. Setting installs a copy as /// terminal-owned tracked state. Set null to clear the active selection. Selection? get selection { - final (code, raw) = bindings.terminalGetSelection(_handle); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + final raw = bindings.selection.terminalGetSelection(_terminalHandle); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Sets the active selection on the terminal screen. @@ -555,28 +567,36 @@ final class Terminal with Listenable { /// Assign null to clear the active selection. Non-null selections must belong /// to this terminal. set selection(Selection? value) { - checkCode(bindings.terminalSetSelection(_handle, _checkedSelection(value))); + bindings.selection.terminalSetSelection( + _terminalHandle, + _checkedSelection(value), + ); notifyListeners(); } /// Terminal title as set by OSC 0 or OSC 2 sequences. /// - /// The returned value is borrowed from the terminal. Read it immediately - /// after [write] or [reset]; it may change on the next call to either. - String get title => check(bindings.terminalGetTitle(_handle)); + /// The returned string is a Dart-owned snapshot and remains valid after + /// subsequent terminal operations. + String get title => bindings.terminal.terminalGetTitle(_terminalHandle); /// Sets the terminal title, or clears it if null. set title(String? value) { - checkCode(bindings.terminalSetTitle(_handle, value)); + bindings.terminal.terminalSetTitle(_terminalHandle, value); } /// Total number of rows: active grid rows plus scrollback rows. - int get totalRows => check(bindings.terminalGetTotalRows(_handle)); + int get totalRows => bindings.terminal.terminalGetTotalRows(_terminalHandle); /// Total terminal width in pixels (cols * cell width). - int get widthPx => check(bindings.terminalGetWidthPx(_handle)); + int get widthPx => bindings.terminal.terminalGetWidthPx(_terminalHandle); - int get _handleOrNull => _disposed ? 0 : _handle; + LibGhosttyHandle? get _handleOrNull => _disposed ? null : _handle; + + LibGhosttyHandle get _terminalHandle { + if (_disposed) throw StateError('Terminal has been disposed'); + return _handle; + } /// Compresses eligible scrollback storage without changing terminal data. /// @@ -598,20 +618,21 @@ final class Terminal with Listenable { TerminalCompressionResult compress({ TerminalCompressionMode mode = .incremental, }) { - return check(bindings.terminalCompress(_handle, mode)); + final result = bindings.terminal.terminalCompress(_terminalHandle, mode); + return result; } /// Releases the native terminal handle and clears registered callbacks. /// - /// Must be called to free resources; the terminal must not be used - /// afterward. + /// Calling [dispose] more than once is safe. Every other member throws a + /// [StateError] after disposal. Do not call [dispose] from an active terminal + /// callback. void dispose() { if (_disposed) return; + bindings.terminal.terminalFree(_handle); + _finalizer.detach(this); _disposed = true; clearListeners(); - _finalizer.detach(this); - bindings.terminalDisposeCallbacks(_handle); - bindings.terminalFree(_handle); } /// Formats an explicit or active selection. @@ -624,26 +645,27 @@ final class Terminal with Listenable { bool trim = false, Selection? selection, }) { - final (code, text) = bindings.terminalSelectionFormat( - _handle, + return bindings.selection.terminalSelectionFormat( + _terminalHandle, format, unwrap: unwrap, trim: trim, selection: _checkedSelection(selection), ); - if (code == .noValue) return null; - checkCode(code); - return text; } /// Queries whether the given terminal [mode] is currently enabled. bool modeGet(TerminalMode mode) { - return check(bindings.terminalModeGet(_handle, mode.value)); + return bindings.terminal.terminalModeGet(_terminalHandle, mode.value); } /// Enables or disables the given terminal [mode]. void modeSet(TerminalMode mode, {required bool value}) { - checkCode(bindings.terminalModeSet(_handle, mode.value, value: value)); + bindings.terminal.terminalModeSet( + _terminalHandle, + mode.value, + value: value, + ); } /// Sets the current and reset-default value of the given terminal [mode]. @@ -651,14 +673,18 @@ final class Terminal with Listenable { /// Some transition or mirrored modes cannot be configured as reset defaults /// and throw [InvalidValueException]. void modeSetDefault(TerminalMode mode, {required bool value}) { - checkCode( - bindings.terminalModeSetDefault(_handle, mode.value, value: value), + bindings.terminal.terminalModeSetDefault( + _terminalHandle, + mode.value, + value: value, ); } /// Performs a full reset (RIS): resets modes, scrollback, scrolling region, /// and screen contents to defaults while preserving terminal dimensions. - void reset() => bindings.terminalReset(_handle); + void reset() { + bindings.terminal.terminalReset(_terminalHandle); + } /// Resizes the terminal grid to the given cell dimensions. /// @@ -682,10 +708,9 @@ final class Terminal with Listenable { int cellWidthPx = 0, int cellHeightPx = 0, }) { - late final Result result; try { - result = bindings.terminalResize( - _handle, + bindings.terminal.terminalResize( + _terminalHandle, cols, rows, cellWidthPx, @@ -698,12 +723,13 @@ final class Terminal with Listenable { Error.throwWithStackTrace(error, stackTrace); } } - checkCode(result); notifyListeners(); } /// Scrolls the viewport to the bottom (active area). - void scrollToBottom() => bindings.terminalScrollViewport(_handle, .bottom, 0); + void scrollToBottom() { + bindings.terminal.terminalScrollViewport(_terminalHandle, .bottom, 0); + } /// Scrolls the viewport to an absolute row in the scrollable area. /// @@ -716,17 +742,19 @@ final class Terminal with Listenable { /// can be passed here to restore that viewport position. void scrollToRow(int row) { RangeError.checkNotNegative(row, 'row'); - bindings.terminalScrollViewport(_handle, .row, row); + bindings.terminal.terminalScrollViewport(_terminalHandle, .row, row); } /// Scrolls the viewport to the top of the scrollback history. - void scrollToTop() => bindings.terminalScrollViewport(_handle, .top, 0); + void scrollToTop() { + bindings.terminal.terminalScrollViewport(_terminalHandle, .top, 0); + } /// Scrolls the viewport by [delta] rows. Positive values scroll down /// (toward the active area), negative values scroll up (toward history). void scrollViewport(int delta) { if (delta == 0) return; - bindings.terminalScrollViewport(_handle, .delta, delta); + bindings.terminal.terminalScrollViewport(_terminalHandle, .delta, delta); } /// Derives a selection snapshot covering all selectable terminal content. @@ -734,10 +762,8 @@ final class Terminal with Listenable { /// The returned selection is not installed as the terminal's active /// selection. Assign it to [selection] to make it active. Selection? selectAll() { - final (code, raw) = bindings.terminalSelectAll(_handle); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + final raw = bindings.selection.terminalSelectAll(_terminalHandle); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Derives a line selection snapshot under [ref]. @@ -749,15 +775,13 @@ final class Terminal with Listenable { List? whitespace, bool semanticPromptBoundary = false, }) { - final (code, raw) = bindings.terminalSelectLine( - _handle, + final raw = bindings.selection.terminalSelectLine( + _terminalHandle, _checkedRef(ref), whitespace: whitespace, semanticPromptBoundary: semanticPromptBoundary, ); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Derives a semantic command-output selection snapshot under [ref]. @@ -765,13 +789,11 @@ final class Terminal with Listenable { /// The returned selection is not installed as the terminal's active /// selection. Assign it to [selection] to make it active. Selection? selectOutput(GridRef ref) { - final (code, raw) = bindings.terminalSelectOutput( - _handle, + final raw = bindings.selection.terminalSelectOutput( + _terminalHandle, _checkedRef(ref), ); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Derives a word selection snapshot under [ref]. @@ -779,14 +801,12 @@ final class Terminal with Listenable { /// The returned selection is not installed as the terminal's active /// selection. Assign it to [selection] to make it active. Selection? selectWord(GridRef ref, {List? boundaryCodepoints}) { - final (code, raw) = bindings.terminalSelectWord( - _handle, + final raw = bindings.selection.terminalSelectWord( + _terminalHandle, _checkedRef(ref), boundaryCodepoints: boundaryCodepoints, ); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Derives the nearest word selection snapshot between two grid references. @@ -798,15 +818,13 @@ final class Terminal with Listenable { GridRef end, { List? boundaryCodepoints, }) { - final (code, raw) = bindings.terminalSelectWordBetween( - _handle, + final raw = bindings.selection.terminalSelectWordBetween( + _terminalHandle, _checkedRef(start, 'start'), _checkedRef(end, 'end'), boundaryCodepoints: boundaryCodepoints, ); - if (code == .noValue) return null; - checkCode(code); - return Selection._fromRaw(this, raw!); + return raw == null ? null : Selection._fromRaw(this, raw); } /// Sets the maximum bytes the APC handler will buffer for all protocols. @@ -814,12 +832,15 @@ final class Terminal with Listenable { /// This replaces protocol-specific overrides. Pass null to remove all /// overrides and use the built-in defaults. void setApcBufferLimit(int? bytes) { - checkCode(bindings.terminalSetApcBufferLimit(_handle, bytes)); + bindings.terminal.terminalSetApcBufferLimit(_terminalHandle, bytes); } /// Enables or disables Glyph Protocol APC handling. void setGlyphProtocol({required bool enabled}) { - checkCode(bindings.terminalSetGlyphProtocol(_handle, enabled: enabled)); + bindings.terminal.terminalSetGlyphProtocol( + _terminalHandle, + enabled: enabled, + ); } /// Sets the maximum bytes the APC handler will buffer for Kitty graphics @@ -829,40 +850,40 @@ final class Terminal with Listenable { /// Pass null to remove the Kitty-specific override and use the built-in /// Kitty graphics default. void setKittyApcBufferLimit(int? bytes) { - checkCode(bindings.terminalSetKittyApcBufferLimit(_handle, bytes)); + bindings.terminal.terminalSetKittyApcBufferLimit(_terminalHandle, bytes); } /// Enables or disables the file medium for Kitty image loading. void setKittyFileMedium({required bool enabled}) { - checkCode( - bindings.terminalSetKittyImageMediumFile(_handle, enabled: enabled), + bindings.terminal.terminalSetKittyImageMediumFile( + _terminalHandle, + enabled: enabled, ); } /// Enables or disables the shared memory medium for Kitty image loading. void setKittySharedMemMedium({required bool enabled}) { - checkCode( - bindings.terminalSetKittyImageMediumSharedMem(_handle, enabled: enabled), + bindings.terminal.terminalSetKittyImageMediumSharedMem( + _terminalHandle, + enabled: enabled, ); } /// Restricts Kitty temporary-file image loading to [directory]. /// - /// Passing null disables the medium. The directory is copied, so callers do - /// not need to retain the string. Throws [InvalidValueException] when the - /// directory is empty and [OutOfMemoryException] when it exceeds the native - /// path capacity. + /// Passing null disables the medium. An empty directory remains a value and + /// does not disable it. The directory is copied, so callers do not need to + /// retain the string. Throws [OutOfMemoryException] when the directory + /// exceeds the native path capacity. /// /// ```dart /// terminal.setKittyTempFileDirectory('/tmp/terminal-images'); /// ``` void setKittyTempFileDirectory(String? directory) { - if (directory == '') { - throw const InvalidValueException( - 'Kitty temporary-file directory must not be empty.', - ); - } - checkCode(bindings.terminalSetKittyImageMediumTempFile(_handle, directory)); + bindings.terminal.terminalSetKittyImageMediumTempFile( + _terminalHandle, + directory, + ); } /// Enables or disables title reports in response to `CSI 21 t` queries. @@ -875,16 +896,17 @@ final class Terminal with Listenable { /// This setting is independent of [onTitleChanged], which observes title /// changes made by OSC 0 or OSC 2. void setTitleReports({required bool enabled}) { - checkCode(bindings.terminalSetTitleReport(_handle, enabled: enabled)); + bindings.terminal.terminalSetTitleReport(_terminalHandle, enabled: enabled); } /// Feeds raw VT-encoded bytes into the terminal for processing. /// /// Malformed input is logged internally but does not corrupt state or throw. - /// All registered callbacks fire synchronously during this call. Callers - /// **must not** call [write] from within a callback (no reentrancy). If one - /// or more callbacks throw, processing finishes and listeners are notified - /// before the first exception is rethrown with its original stack trace. + /// Callbacks fire synchronously during this call and must not call [write] + /// for this terminal. Writes to another terminal are allowed when that + /// terminal's own concurrency rules permit them. If one or more callbacks + /// throw, processing finishes and listeners are notified before the first + /// exception is rethrown with its original stack trace. /// /// Sequences requiring output (device status reports, mode queries) are /// silently ignored unless [onWritePty] is registered. Notifies listeners @@ -895,7 +917,7 @@ final class Terminal with Listenable { /// ``` void write(Uint8List data) { try { - bindings.terminalVtWrite(_handle, data); + bindings.terminal.terminalVtWrite(_terminalHandle, data); } on Object catch (error, stackTrace) { try { notifyListeners(); @@ -906,6 +928,25 @@ final class Terminal with Listenable { notifyListeners(); } + /// Writes the replay-safe continuation to [writer]. + /// + /// The continuation is the exact byte suffix needed to reconstruct + /// unfinished VT or UTF-8 input. [writer] is called synchronously and may + /// receive more than one non-empty chunk. Return `true` only after the + /// complete chunk has been accepted; returning `false` aborts the operation + /// with [IoException]. The callback must not call operations on this + /// terminal, and this method must be serialized with [write] and other + /// terminal operations. + /// + /// Throws [InvalidValueException] when tracking is disabled or the current + /// unfinished input cannot be reconstructed, [IoException] when [writer] + /// rejects a chunk, and [LimitExceededException] when output accounting + /// overflows. If [writer] throws, that exception is rethrown after the C + /// operation completes. + void writeContinuation(ContinuationWriter writer) { + bindings.terminal.terminalContinuationWrite(_terminalHandle, writer); + } + RawGridRef _checkedRef(GridRef ref, [String name = 'ref']) { _checkRefTerminal(ref, name); return ref._value; @@ -928,8 +969,4 @@ final class Terminal with Listenable { throw ArgumentError.value(ref, name, 'must belong to this terminal'); } } - - static T? _optionalValue(CResult result) { - return result.$1 == .noValue ? null : check(result); - } } diff --git a/packages/libghostty/lib/src/impl/terminal/terminal_mode.dart b/packages/libghostty/lib/src/api/terminal/terminal_mode.dart similarity index 96% rename from packages/libghostty/lib/src/impl/terminal/terminal_mode.dart rename to packages/libghostty/lib/src/api/terminal/terminal_mode.dart index 29c697e6..86fb3c8e 100644 --- a/packages/libghostty/lib/src/impl/terminal/terminal_mode.dart +++ b/packages/libghostty/lib/src/api/terminal/terminal_mode.dart @@ -1,7 +1,7 @@ import 'package:meta/meta.dart'; - import '../../bindings/bindings.dart'; -import '../../ffi/libghostty_enums.g.dart'; + +import '../../generated/libghostty_enums.g.dart'; /// A packed 16-bit terminal mode identifier (DEC private or ANSI). /// @@ -136,6 +136,9 @@ extension type const TerminalMode._(int value) { /// DEC private mode 2031: report color scheme. const TerminalMode.colorSchemeReport() : value = 2031; + /// DEC private mode 2033: report terminal visibility state. + const TerminalMode.visibilityReport() : value = 2033; + /// DEC private mode 2048: in-band size reports. const TerminalMode.inBandResize() : value = 2048; @@ -176,6 +179,6 @@ extension type const TerminalMode._(int value) { /// print('DECRPM response: ${report.codeUnits}'); /// ``` String encodeReport(ModeReportState state) { - return check(bindings.modeReportEncode(value, state)); + return bindings.utility.modeReportEncode(value, state); } } diff --git a/packages/libghostty/lib/src/impl/terminal/tracked_grid_ref.dart b/packages/libghostty/lib/src/api/terminal/tracked_grid_ref.dart similarity index 69% rename from packages/libghostty/lib/src/impl/terminal/tracked_grid_ref.dart rename to packages/libghostty/lib/src/api/terminal/tracked_grid_ref.dart index 8541a9c6..0c7b9474 100644 --- a/packages/libghostty/lib/src/impl/terminal/tracked_grid_ref.dart +++ b/packages/libghostty/lib/src/api/terminal/tracked_grid_ref.dart @@ -9,6 +9,8 @@ part of 'terminal.dart'; /// /// If the tracked cell is discarded by reset, screen replacement, or terminal /// disposal, [hasValue] becomes false and [snapshot] / [positionIn] return null. +/// Calling [dispose] more than once is safe; every other member throws +/// [StateError] after disposal. /// /// Not intended for render loops. Use [RenderState] with [RowIterator] and /// [CellIterator] for performance-critical rendering. @@ -23,10 +25,11 @@ part of 'terminal.dart'; /// tracked.dispose(); /// ``` final class TrackedGridRef { - static final _finalizer = Finalizer(bindings.trackedGridRefFree); + static final _finalizer = Finalizer(bindings.render.trackedGridRefFree); - final int _handle; + final LibGhosttyHandle _handle; final Terminal _terminal; + var _disposed = false; /// Resolves and tracks the grid cell at [position] in the coordinate /// space identified by [pointTag]. @@ -43,23 +46,28 @@ final class TrackedGridRef { Position position, { PointTag pointTag = .active, }) : _terminal = terminal, - _handle = check( - bindings.terminalGridRefTrack(terminal._handle, pointTag, position), + _handle = bindings.render.terminalGridRefTrack( + terminal._terminalHandle, + pointTag, + position, ) { _finalizer.attach(this, _handle, detach: this); } /// Whether this reference currently resolves to a meaningful grid position. - bool get hasValue => bindings.trackedGridRefHasValue(_handle); + bool get hasValue { + _ensureAlive(); + return bindings.render.trackedGridRefHasValue(_handle); + } /// Releases the native tracked grid reference handle. /// - /// Must be called to free resources; the reference must not be used - /// afterward. It is safe to dispose after the creating terminal has been - /// disposed. + /// It is safe to dispose after the creating terminal has been disposed. void dispose() { + if (_disposed) return; + bindings.render.trackedGridRefFree(_handle); _finalizer.detach(this); - bindings.trackedGridRefFree(_handle); + _disposed = true; } /// Converts this tracked reference to coordinates in the given coordinate @@ -68,10 +76,8 @@ final class TrackedGridRef { /// Returns null if the tracked location has been discarded or cannot be /// represented in [pointTag]. Position? positionIn(PointTag pointTag) { - final (code, position) = bindings.trackedGridRefPoint(_handle, pointTag); - if (code == Result.noValue) return null; - checkCode(code); - return position; + _ensureAlive(); + return bindings.render.trackedGridRefPoint(_handle, pointTag); } /// Moves this tracked reference to a new position. @@ -79,13 +85,12 @@ final class TrackedGridRef { /// The new position is resolved against the same terminal that created this /// tracked reference. The terminal must not have been disposed. void set(Position position, {PointTag pointTag = .active}) { - checkCode( - bindings.trackedGridRefSet( - _handle, - _terminal._handle, - pointTag, - position, - ), + _ensureAlive(); + bindings.render.trackedGridRefSet( + _handle, + _terminal._terminalHandle, + pointTag, + position, ); } @@ -94,9 +99,12 @@ final class TrackedGridRef { /// The returned [GridRef] follows normal grid-reference lifetime rules. /// Returns null if this tracked reference no longer has a meaningful value. GridRef? snapshot() { - final (code, ref) = bindings.trackedGridRefSnapshot(_handle); - if (code == .noValue) return null; - checkCode(code); - return GridRef._fromValue(_terminal, ref); + _ensureAlive(); + final ref = bindings.render.trackedGridRefSnapshot(_handle); + return ref == null ? null : GridRef._fromValue(_terminal, ref); + } + + void _ensureAlive() { + if (_disposed) throw StateError('TrackedGridRef has been disposed'); } } diff --git a/packages/libghostty/lib/src/impl/unicode.dart b/packages/libghostty/lib/src/api/unicode.dart similarity index 94% rename from packages/libghostty/lib/src/impl/unicode.dart rename to packages/libghostty/lib/src/api/unicode.dart index 8bd01ed3..c99aa49e 100644 --- a/packages/libghostty/lib/src/impl/unicode.dart +++ b/packages/libghostty/lib/src/api/unicode.dart @@ -15,7 +15,7 @@ import '../bindings/bindings.dart'; /// when terminal mode 2027 is enabled. int unicodeCodepointWidth(int codepoint) { RangeError.checkNotNegative(codepoint, 'codepoint'); - return bindings.unicodeCodepointWidth(codepoint); + return bindings.utility.unicodeCodepointWidth(codepoint); } /// Measures the first grapheme cluster in [codepoints]. @@ -43,5 +43,5 @@ int unicodeCodepointWidth(int codepoint) { for (final codepoint in codepoints) { RangeError.checkNotNegative(codepoint, 'codepoints'); } - return bindings.unicodeGraphemeWidth(codepoints); + return bindings.utility.unicodeGraphemeWidth(codepoints); } diff --git a/packages/libghostty/lib/src/bindings/bindings.dart b/packages/libghostty/lib/src/bindings/bindings.dart index 3f5f367f..fce8e69f 100644 --- a/packages/libghostty/lib/src/bindings/bindings.dart +++ b/packages/libghostty/lib/src/bindings/bindings.dart @@ -1,2 +1,67 @@ -export 'interface.dart'; -export 'wasm/wasm.dart' if (dart.library.ffi) 'native/native.dart'; +import 'ffi.dart' if (dart.library.js_interop) 'wasm.dart' as platform; +import 'formatter/formatter.dart'; +import 'key/key.dart'; +import 'kitty_graphics/kitty_graphics.dart'; +import 'mouse/mouse.dart'; +import 'parser/parser.dart'; +import 'render/render.dart'; +import 'selection/selection.dart'; +import 'system/sys.dart'; +import 'terminal/terminal.dart'; +import 'utility/utility.dart'; + +export 'wasm/bootstrap.dart' + if (dart.library.ffi) 'wasm/bootstrap_stub.dart' + show initializeForWeb; + +/// The bindings for the active platform. +Bindings get bindings => platform.bindings; + +/// The focused platform bindings used by the public resource wrappers. +/// +/// This class only composes module adapters. It does not forward behavior or +/// expose generated declarations. +final class Bindings { + /// Terminal and terminal-owned operations. + final TerminalBindings terminal; + + /// Keyboard event and encoder operations. + final KeyBindings key; + + /// Mouse event and encoder operations. + final MouseBindings mouse; + + /// OSC and SGR parser operations. + final ParserBindings parser; + + /// Formatter operations. + final FormatterBindings formatter; + + /// Stateless utility operations. + final UtilityBindings utility; + + /// Render-state, row, cell, and grid-reference operations. + final RenderBindings render; + + /// Selection and gesture operations. + final SelectionBindings selection; + + /// Kitty graphics operations. + final KittyGraphicsBindings kittyGraphics; + + /// Process-global system operations. + final SystemBindings system; + + const Bindings({ + required this.terminal, + required this.key, + required this.mouse, + required this.parser, + required this.formatter, + required this.utility, + required this.render, + required this.selection, + required this.kittyGraphics, + required this.system, + }); +} diff --git a/packages/libghostty/lib/src/bindings/ffi.dart b/packages/libghostty/lib/src/bindings/ffi.dart new file mode 100644 index 00000000..0cfde7d3 --- /dev/null +++ b/packages/libghostty/lib/src/bindings/ffi.dart @@ -0,0 +1,25 @@ +import 'bindings.dart'; +import 'formatter/ffi.dart'; +import 'key/ffi.dart'; +import 'kitty_graphics/ffi.dart'; +import 'mouse/ffi.dart'; +import 'parser/ffi.dart'; +import 'render/ffi.dart'; +import 'selection/ffi.dart'; +import 'system/ffi.dart'; +import 'terminal/ffi.dart'; +import 'utility/ffi.dart'; + +/// The eagerly initialized native binding holder. +final bindings = Bindings( + terminal: FfiTerminalBindings(), + key: FfiKeyBindings(), + mouse: const FfiMouseBindings(), + parser: const FfiParserBindings(), + formatter: FfiFormatterBindings(), + utility: const FfiUtilityBindings(), + render: FfiRenderBindings(), + selection: FfiSelectionBindings(), + kittyGraphics: FfiKittyGraphicsBindings(), + system: FfiSystemBindings(), +); diff --git a/packages/libghostty/lib/src/bindings/formatter/ffi.dart b/packages/libghostty/lib/src/bindings/formatter/ffi.dart new file mode 100644 index 00000000..3bfbf6ca --- /dev/null +++ b/packages/libghostty/lib/src/bindings/formatter/ffi.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import '../../generated/libghostty.g.dart' hide String; +import '../../generated/libghostty_enums.g.dart'; +import '../../types/types.dart'; +import '../result_helpers.dart'; +import '../types.dart'; +import 'formatter.dart'; + +final class FfiFormatterBindings implements FormatterBindings { + var _formatBuffer = calloc(4096); + var _formatBufferCapacity = 4096; + final _written = calloc(); + + FfiFormatterBindings(); + + @override + String formatterFormat(LibGhosttyHandle formatter) { + var result = ghostty_formatter_format_buf( + Pointer.fromAddress(formatter.value), + _formatBuffer, + _formatBufferCapacity, + _written, + ); + if (result == .outOfSpace) { + _growFormatBuffer(_written.value); + result = ghostty_formatter_format_buf( + Pointer.fromAddress(formatter.value), + _formatBuffer, + _formatBufferCapacity, + _written, + ); + } + checkResultCode(result.value, operation: 'ghostty_formatter_format_buf'); + final length = _written.value; + return length == 0 ? '' : utf8.decode(_formatBuffer.asTypedList(length)); + } + + @override + void formatterFree(LibGhosttyHandle formatter) { + ghostty_formatter_free(Pointer.fromAddress(formatter.value)); + } + + @override + LibGhosttyHandle formatterTerminalNew( + LibGhosttyHandle terminal, + FormatterFormat format, { + bool unwrap = false, + bool trim = false, + FormatterExtra extra = const FormatterExtra(), + RawSelection? selection, + }) { + return using((arena) { + final out = arena>(); + final options = arena(); + options.ref + ..size = sizeOf() + ..emitAsInt = format.value + ..unwrap = unwrap + ..trim = trim; + options.ref.extra + ..size = sizeOf() + ..palette = extra.palette + ..modes = extra.modes + ..scrolling_region = extra.scrollingRegion + ..tabstops = extra.tabstops + ..pwd = extra.pwd + ..keyboard = extra.keyboard; + options.ref.extra.screen + ..size = sizeOf() + ..cursor = extra.cursor + ..style = extra.style + ..hyperlink = extra.hyperlink + ..protection = extra.protection + ..kitty_keyboard = extra.kittyKeyboard + ..charsets = extra.charsets; + + if (selection == null) { + options.ref.selection = nullptr; + } else { + final selected = arena(); + _writeSelection(selected.ref, selection); + options.ref.selection = selected; + } + + final result = ghostty_formatter_terminal_new( + nullptr, + out, + Pointer.fromAddress(terminal.value), + options.ref, + ); + checkResultCode( + result.value, + operation: 'ghostty_formatter_terminal_new', + ); + return .fromAddress(out.value.address); + }); + } + + void _growFormatBuffer(int required) { + if (required <= _formatBufferCapacity) return; + final replacement = calloc(required); + calloc.free(_formatBuffer); + _formatBuffer = replacement; + _formatBufferCapacity = required; + } + + static void _writeGridRef(GridRef target, RawGridRef value) { + target + ..size = sizeOf() + ..node = Pointer.fromAddress(value.node) + ..x = value.x + ..y = value.y; + } + + static void _writeSelection(Selection target, RawSelection selection) { + target + ..size = sizeOf() + ..rectangle = selection.rectangle; + _writeGridRef(target.start, selection.start); + _writeGridRef(target.end, selection.end); + } +} diff --git a/packages/libghostty/lib/src/bindings/formatter/formatter.dart b/packages/libghostty/lib/src/bindings/formatter/formatter.dart new file mode 100644 index 00000000..ef067162 --- /dev/null +++ b/packages/libghostty/lib/src/bindings/formatter/formatter.dart @@ -0,0 +1,18 @@ +import '../../generated/libghostty_enums.g.dart'; +import '../../types/types.dart'; +import '../types.dart'; + +abstract interface class FormatterBindings { + String formatterFormat(LibGhosttyHandle formatter); + + void formatterFree(LibGhosttyHandle formatter); + + LibGhosttyHandle formatterTerminalNew( + LibGhosttyHandle terminal, + FormatterFormat format, { + bool unwrap = false, + bool trim = false, + FormatterExtra extra = const FormatterExtra(), + RawSelection? selection, + }); +} diff --git a/packages/libghostty/lib/src/bindings/formatter/wasm.dart b/packages/libghostty/lib/src/bindings/formatter/wasm.dart new file mode 100644 index 00000000..16c16e4f --- /dev/null +++ b/packages/libghostty/lib/src/bindings/formatter/wasm.dart @@ -0,0 +1,203 @@ +import 'dart:convert'; + +import '../../generated/libghostty_enums.g.dart'; +import '../../generated/libghostty_wasm.g.dart'; +import '../../types/types.dart'; +import '../result_helpers.dart'; +import '../types.dart'; +import '../wasm/allocator.dart'; +import '../wasm/layouts.dart'; +import '../wasm/memory.dart'; +import '../wasm/scratch.dart'; +import 'formatter.dart'; + +final class WasmFormatterBindings implements FormatterBindings { + final Memory _memory; + final Layouts _layout; + final GhosttyExports _exports; + final WasmScratchPool _scratch; + late int _formatBuffer; + late int _formatBufferCapacity; + late int _written; + + WasmFormatterBindings(this._exports, this._layout) + : _memory = Memory(_exports), + _scratch = WasmScratchPool( + WasmExportScratchAllocator(_exports), + maxVariableLength: WasmScratchPool.defaultMaxVariableLength, + ) { + _formatBufferCapacity = 4096; + _formatBuffer = _requirePointer( + _exports.allocateU8Array(_formatBufferCapacity), + ); + _written = _requirePointer(_exports.allocateUsize()); + } + + @override + String formatterFormat(LibGhosttyHandle formatter) { + var result = _exports.ghostty_formatter_format_buf( + formatter.value, + _formatBuffer, + _formatBufferCapacity, + _written, + ); + if (result == Result.outOfSpace.value) { + _growFormatBuffer(_memory.readU32(_written)); + result = _exports.ghostty_formatter_format_buf( + formatter.value, + _formatBuffer, + _formatBufferCapacity, + _written, + ); + } + checkResultCode(result, operation: 'ghostty_formatter_format_buf'); + final length = _memory.readU32(_written); + return length == 0 + ? '' + : utf8.decode(_memory.readBytes(_formatBuffer, length)); + } + + @override + void formatterFree(LibGhosttyHandle formatter) { + _exports.ghostty_formatter_free(formatter.value); + } + + @override + LibGhosttyHandle formatterTerminalNew( + LibGhosttyHandle terminal, + FormatterFormat format, { + bool unwrap = false, + bool trim = false, + FormatterExtra extra = const FormatterExtra(), + RawSelection? selection, + }) { + final frame = _scratch.acquire(const []); + try { + final out = frame.variableAddress( + 0, + wasm32PointerSize, + alignment: wasm32PointerSize, + ); + final options = frame.variableAddress( + 1, + _layout.formatterOptsSize, + alignment: wasm32PointerSize, + ); + for (var i = 0; i < _layout.formatterOptsSize; i++) { + _memory.writeU8(options + i, 0); + } + _memory.writeU32(options, _layout.formatterOptsSize); + _memory.writeU32(options + _layout.formatterOptsFormat, format.value); + _memory.writeU8(options + _layout.formatterOptsUnwrap, unwrap ? 1 : 0); + _memory.writeU8(options + _layout.formatterOptsTrim, trim ? 1 : 0); + + final extraBase = options + _layout.formatterOptsExtra; + _memory.writeU32(extraBase, _layout.formatterTermExtraSize); + _memory.writeU8( + extraBase + _layout.formatterTermExtraPalette, + extra.palette ? 1 : 0, + ); + _memory.writeU8( + extraBase + _layout.formatterTermExtraModes, + extra.modes ? 1 : 0, + ); + _memory.writeU8( + extraBase + _layout.formatterTermExtraScrollingRegion, + extra.scrollingRegion ? 1 : 0, + ); + _memory.writeU8( + extraBase + _layout.formatterTermExtraTabstops, + extra.tabstops ? 1 : 0, + ); + _memory.writeU8( + extraBase + _layout.formatterTermExtraPwd, + extra.pwd ? 1 : 0, + ); + _memory.writeU8( + extraBase + _layout.formatterTermExtraKeyboard, + extra.keyboard ? 1 : 0, + ); + + final screenBase = extraBase + _layout.formatterTermExtraScreen; + _memory.writeU32(screenBase, _layout.formatterScreenExtraSize); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraCursor, + extra.cursor ? 1 : 0, + ); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraStyle, + extra.style ? 1 : 0, + ); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraHyperlink, + extra.hyperlink ? 1 : 0, + ); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraProtection, + extra.protection ? 1 : 0, + ); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraKittyKeyboard, + extra.kittyKeyboard ? 1 : 0, + ); + _memory.writeU8( + screenBase + _layout.formatterScreenExtraCharsets, + extra.charsets ? 1 : 0, + ); + + var selectionPointer = 0; + if (selection != null) { + selectionPointer = frame.variableAddress( + 2, + _layout.selectionSize, + alignment: wasm32PointerSize, + ); + _memory.writeU32(selectionPointer, _layout.selectionSize); + _writeGridRef( + selectionPointer + _layout.selectionStart, + selection.start, + ); + _writeGridRef(selectionPointer + _layout.selectionEnd, selection.end); + _memory.writeU8( + selectionPointer + _layout.selectionRectangle, + selection.rectangle ? 1 : 0, + ); + } + _memory.writeU32( + options + _layout.formatterOptsSelection, + selectionPointer, + ); + + final result = _exports.ghostty_formatter_terminal_new( + 0, + out, + terminal.value, + options, + ); + checkResultCode(result, operation: 'ghostty_formatter_terminal_new'); + return .fromAddress(_memory.readPtr(out)); + } finally { + frame.release(); + } + } + + void _growFormatBuffer(int required) { + if (required <= _formatBufferCapacity) return; + final replacement = _requirePointer(_exports.allocateU8Array(required)); + _exports.freeU8Array(_formatBuffer, _formatBufferCapacity); + _formatBuffer = replacement; + _formatBufferCapacity = required; + } + + int _requirePointer(int pointer) { + if (pointer == 0) throw const OutOfMemoryException(); + return pointer; + } + + void _writeGridRef(int pointer, RawGridRef value) { + _memory.writeU32(pointer, _layout.gridRefSize); + _memory.writeU32(pointer + _layout.gridRefNode, value.node); + _memory.writeU16(pointer + _layout.gridRefX, value.x); + _memory.writeU16(pointer + _layout.gridRefY, value.y); + } +} diff --git a/packages/libghostty/lib/src/bindings/interface.dart b/packages/libghostty/lib/src/bindings/interface.dart deleted file mode 100644 index 5c39c11a..00000000 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ /dev/null @@ -1,531 +0,0 @@ -import 'dart:typed_data'; - -import '../ffi/libghostty_enums.g.dart'; -import 'types/types.dart'; - -export 'types/types.dart'; - -/// Platform-independent interface for libghostty-vt bindings. -/// -/// Implemented by `NativeBindings` (dart:ffi) and `WasmBindings` -/// (dart:js_interop). Handles are opaque `int` values on both platforms. -/// -/// Methods that wrap C functions returning `GhosttyResult` return a [Result] -/// enum value. For methods that also produce a value, the return type is a -/// [CResult] record. Callers decide how to handle non-success results -/// (e.g. via [checkCode] or [check]). -abstract interface class GhosttyBindings { - CResult keyEventNew(); - void keyEventFree(int handle); - void keyEventSetAction(int handle, KeyAction action); - KeyAction keyEventGetAction(int handle); - void keyEventSetKey(int handle, Key key); - Key keyEventGetKey(int handle); - void keyEventSetMods(int handle, int mods); - int keyEventGetMods(int handle); - void keyEventSetConsumedMods(int handle, int mods); - int keyEventGetConsumedMods(int handle); - void keyEventSetComposing(int handle, {required bool composing}); - bool keyEventGetComposing(int handle); - void keyEventSetUtf8(int handle, String? text); - String? keyEventGetUtf8(int handle); - void keyEventSetUnshiftedCodepoint(int handle, int codepoint); - int keyEventGetUnshiftedCodepoint(int handle); - - CResult keyEncoderNew(); - void keyEncoderFree(int handle); - void keyEncoderSetBoolOpt( - int handle, - KeyEncoderOption option, { - required bool value, - }); - void keyEncoderSetKittyFlags(int handle, int flags); - void keyEncoderSetOptionAsAlt(int handle, OptionAsAlt value); - void keyEncoderSetOptFromTerminal(int encoder, int terminal); - CResult keyEncoderEncode(int encoder, int event); - - CResult mouseEventNew(); - void mouseEventFree(int handle); - void mouseEventSetAction(int handle, MouseAction action); - MouseAction mouseEventGetAction(int handle); - void mouseEventSetButton(int handle, MouseButton button); - void mouseEventClearButton(int handle); - - CResult mouseEventGetButton(int handle); - void mouseEventSetMods(int handle, int mods); - int mouseEventGetMods(int handle); - void mouseEventSetPosition(int handle, double x, double y); - (double x, double y) mouseEventGetPosition(int handle); - - CResult mouseEncoderNew(); - void mouseEncoderFree(int handle); - void mouseEncoderSetBoolOpt( - int handle, - MouseEncoderOption option, { - required bool value, - }); - void mouseEncoderSetTrackingMode(int handle, MouseTrackingMode mode); - void mouseEncoderSetFormat(int handle, MouseFormat format); - void mouseEncoderSetSize(int handle, MouseEncoderSize size); - void mouseEncoderSetOptFromTerminal(int encoder, int terminal); - void mouseEncoderReset(int handle); - CResult mouseEncoderEncode(int encoder, int event); - - CResult oscNew(); - void oscFree(int handle); - void oscFeedByte(int handle, int byte); - int oscEnd(int handle, int terminator); - OscCommandType oscCommandType(int command); - String? oscCommandWindowTitle(int command); - void oscReset(int handle); - - CResult sgrNew(); - void sgrFree(int handle); - Result sgrSetParams(int handle, List params, List? separators); - SgrAttribute? sgrNext(int handle); - void sgrReset(int handle); - - bool pasteIsSafe(String data); - - double colorContrast(RgbColor a, RgbColor b); - double colorLuminance(RgbColor color); - double colorPerceivedLuminance(RgbColor color); - List colorPaletteDefault(); - List colorPaletteGenerate({ - List? base, - Set skip = const {}, - required RgbColor background, - required RgbColor foreground, - required bool harmonious, - }); - CResult colorParse(String value); - CResult<({int index, RgbColor color})> colorParsePaletteEntry(String value); - CResult colorParseX11(String name); - List colorX11Names(); - CResult colorSchemeReportEncode(ColorScheme scheme); - - int unicodeCodepointWidth(int codepoint); - ({int consumed, int width}) unicodeGraphemeWidth(List codepoints); - - CResult terminalNew(int cols, int rows); - void terminalFree(int handle); - void terminalVtWrite(int handle, Uint8List data); - Result terminalResize( - int handle, - int cols, - int rows, - int cellWidthPx, - int cellHeightPx, - ); - void terminalReset(int handle); - void terminalScrollViewport( - int handle, - TerminalScrollViewportTag tag, - int delta, - ); - CResult terminalCompressionActivity(int handle); - CResult terminalCompress( - int handle, - TerminalCompressionMode mode, - ); - CResult terminalGetCols(int handle); - CResult terminalGetRows(int handle); - CResult terminalGetCursorX(int handle); - CResult terminalGetCursorY(int handle); - CResult terminalGetCursorVisible(int handle); - CResult terminalGetCursorPendingWrap(int handle); - CResult terminalGetActiveScreen(int handle); - CResult terminalGetKittyKeyboardFlags(int handle); - CResult terminalGetScrollbar(int handle); - CResult terminalModeGet(int handle, int mode); - Result terminalModeSet(int handle, int mode, {required bool value}); - Result terminalModeSetDefault(int handle, int mode, {required bool value}); - Result terminalSetTitleReport(int handle, {required bool enabled}); - CResult terminalGetTitle(int handle); - CResult terminalGetPwd(int handle); - CResult terminalGetTotalRows(int handle); - CResult terminalGetScrollbackRows(int handle); - CResult terminalGetScrollbackMaxBytes(int handle); - CResult terminalGetScrollbackMaxLines(int handle); - CResult terminalGetWidthPx(int handle); - CResult terminalGetHeightPx(int handle); - CResult terminalGetGeometry(int handle); - CResult terminalGetViewportActive(int handle); - CResult terminalGetVtProcessingError(int handle); - CResult terminalContinuationGet(int handle); - CResult terminalGetContinuationMaxBytes(int handle); - Result terminalSetContinuationMaxBytes(int handle, int? bytes); - Result terminalSetTitle(int handle, String? title); - Result terminalSetPwd(int handle, String? pwd); - Result terminalSetDefaultCursorShape(int handle, CursorShape? shape); - Result terminalSetDefaultCursorBlink(int handle, {bool? blinking}); - Result terminalSetGlyphProtocol(int handle, {required bool enabled}); - Result terminalSetColorForeground(int handle, RgbColor? color); - Result terminalSetColorBackground(int handle, RgbColor? color); - Result terminalSetColorCursor(int handle, RgbColor? color); - Result terminalSetColorPalette(int handle, List? palette); - - CResult terminalGetColorForeground(int handle); - CResult terminalGetColorBackground(int handle); - CResult terminalGetColorCursor(int handle); - CResult> terminalGetColorPalette(int handle); - - CResult terminalGetColorForegroundDefault(int handle); - CResult terminalGetColorBackgroundDefault(int handle); - CResult terminalGetColorCursorDefault(int handle); - CResult> terminalGetColorPaletteDefault(int handle); - - CResult