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..cf684217 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: | @@ -220,7 +220,7 @@ jobs: - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ env.ZIG_VERSION }} - - uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.pub-cache @@ -246,13 +246,12 @@ jobs: - name: isolate from repo git run: touch ghostty/.git - run: flutter pub get - - run: dart pub global activate very_good_cli - name: build wasm working-directory: packages/libghostty run: dart run tool/build_wasm.dart - name: run wasm tests working-directory: packages/libghostty - run: very_good test --platform=chrome test/wasm + run: dart test --platform=chrome test --exclude-tags=ffi test-flterm-golden: name: test-flterm-linux-golden diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13a08d04..b7303c6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: download artifacts from main - uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 + uses: dawidd6/action-download-artifact@57aa996fc1713cc1579039614f4645a7f4841fd4 # v23 with: workflow: build.yml branch: main 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/CHANGELOG.md b/packages/flterm/CHANGELOG.md index 8e72e18d..b163bd55 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -2,11 +2,50 @@ ## 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 + +- **Incremental rendering**: frame building uses libghostty's dirty-row + iteration and bulk cleanup to reduce row scans and binding calls. +- **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 +- **Cursor viewport state**: cursor and IME preedit rendering honor whether + the cursor has a valid viewport position and use its reported visual style. - **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. +- **Terminal glyph rendering**: Nerd Font symbols use common fallback families, + fit their cell without distortion, and preserve adjacent spacing. Operator + ligatures remain stable across complete runs. +- **Kitty graphics rendering**: unchanged and replacing images remain drawable, + decode pressure is bounded, replacement pixels and geometry publish + atomically, and placement geometry refreshes during layout and resizing. ## 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/example/assets/libghostty-wasm32-freestanding.wasm b/packages/flterm/example/assets/libghostty-wasm32-freestanding.wasm index c5177792..438c11a4 100644 Binary files a/packages/flterm/example/assets/libghostty-wasm32-freestanding.wasm and b/packages/flterm/example/assets/libghostty-wasm32-freestanding.wasm differ 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/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..a2fc3f1f --- /dev/null +++ b/packages/flterm/lib/src/controller/kitty_png_decoder.dart @@ -0,0 +1,26 @@ +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 DecodedImage( + 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..378efd74 --- /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; + +import '../foundation.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'; + +/// 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..d7be7b7b --- /dev/null +++ b/packages/flterm/lib/src/controller/terminal_controller_impl.dart @@ -0,0 +1,730 @@ +part of 'terminal_controller.dart'; + +typedef _Observation = ({ + 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 remain inside 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 InputEncoder _inputEncoder; + late final SelectionSession _selection; + + ColorScheme _colorScheme = .dark; + SurfaceGeometry? _committedGeometry; + TerminalConfig _config; + var _disposed = false; + late _Observation _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 = InputEncoder(_terminal); + _selection = SelectionSession(_terminal, notifyListeners); + installDefaultKittyPngDecoder(); + _wireTerminalCallbacks(); + _applyModes(); + _applyTerminalOptions(); + _observation = _readObservation(); + _terminal.addListener(_onTerminalChanged); + } + + @override + TerminalScreen get activeScreen { + _checkNotDisposed(); + return _terminal.activeScreen; + } + + @override + TerminalConfig get config { + _checkNotDisposed(); + return _config; + } + + @override + set config(TerminalConfig value) { + _checkNotDisposed(); + if (_config == value) return; + _config = value; + _applyModes(); + _applyTerminalOptions(); + _wireTerminalCallbacks(); + _observation = _readObservation(); + notifyListeners(); + } + + bool get cursorBlinking { + _checkNotDisposed(); + return _observation.cursorBlinking; + } + + @override + bool get hasSelection { + _checkNotDisposed(); + return _selection.hasSelection; + } + + bool get isDisposed => _disposed; + + @override + MouseTracking get mouseTracking { + _checkNotDisposed(); + return _observation.mouseTracking; + } + + @override + set onBell(VoidCallback? value) { + _checkNotDisposed(); + _terminal.onBell = value; + } + + @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 onOutput(ValueChanged? value) { + _checkNotDisposed(); + _onOutput = value; + _terminal.onWritePty = value; + } + + @override + set onProgressReport(ValueChanged? value) { + _checkNotDisposed(); + _terminal.onProgressReport = 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 + String get pwd { + _checkNotDisposed(); + return _pwd; + } + + @override + int get scrollbackRows { + _checkNotDisposed(); + return _terminal.scrollbackRows; + } + + @override + Scrollbar get scrollbar { + _checkNotDisposed(); + return _terminal.scrollbar; + } + + Terminal get terminal { + _checkNotDisposed(); + return _terminal; + } + + @override + String get title { + _checkNotDisposed(); + return _terminal.title; + } + + @override + int get totalRows { + _checkNotDisposed(); + return _terminal.totalRows; + } + + Listenable get viewportChanges { + _checkNotDisposed(); + return _viewportChanges; + } + + @override + Mods get virtualMods { + _checkNotDisposed(); + return _virtualMods; + } + + Object attachView() { + _checkNotDisposed(); + if (_viewToken != null) { + throw StateError('TerminalController already has an active view.'); + } + final token = Object(); + _viewToken = token; + return token; + } + + void cancelSelectionGesture() { + _checkNotDisposed(); + _selection.cancelGesture(); + } + + @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, + ); + } + + void detachView(Object token) { + if (_disposed) return; + if (identical(_viewToken, token)) _viewToken = null; + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _viewToken = null; + _terminal.removeListener(_onTerminalChanged); + _viewportChanges.dispose(); + _inputEncoder.dispose(); + _selection.dispose(); + _terminal.dispose(); + super.dispose(); + } + + 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, + }) { + _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 handleTerminalScroll(ScrollInput input) { + _checkNotDisposed(); + if (input.horizontal == 0 && input.vertical == 0) return; + + if (input.reportMouse) { + if (_terminal.mouseTracking == .none) return; + _sendScrollButtons( + input.vertical, + negativeButton: .four, + positiveButton: .five, + input: input, + ); + _sendScrollButtons( + input.horizontal, + negativeButton: .six, + positiveButton: .seven, + input: input, + ); + return; + } + + if (_terminal.mouseTracking != .none || + _terminal.activeScreen != .alternate || + !_terminal.modeGet(const .alternateScroll()) || + input.vertical == 0) { + return; + } + + final up = _observation.cursorKeyApplication ? _appCursorUp : _cursorUp; + final down = _observation.cursorKeyApplication + ? _appCursorDown + : _cursorDown; + final key = input.vertical < 0 ? up : down; + final count = input.vertical.abs(); + _emitOutput(_repeatBytes(key, count)); + } + + 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 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() { + _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(); + } + + void setColorScheme(ColorScheme value) { + _checkNotDisposed(); + if (_colorScheme == value) return; + _colorScheme = value; + } + + @override + void toggleMod(Mods mod) { + _checkNotDisposed(); + _virtualMods = _virtualMods ^ mod; + notifyListeners(); + } + + void updateSelectionAutoscroll(SelectionAutoscrollInput event) { + _checkNotDisposed(); + _selection.handleAutoscroll(event); + } + + void updateSelectionDrag(SelectionDragInput 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 = .fromValue(_config.cursorStyle.value); + _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()); + } + + 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 _handlePwdChanged() { + // The terminal listener publishes the final state after the write ends. + _pwd = _terminal.pwd; + _pwdChanged = true; + _onPwdChanged?.call(); + } + + 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 _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 _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; + if (policy == .onKeystroke || policy == .both) scrollToBottom(); + } + + void _scrollToBottomOnOutput() { + if (_observation.activeScreen == .alternate) return; + final policy = _config.scrollToBottom; + 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; + _terminal.onPwdChanged = _handlePwdChanged; + _terminal.onDeviceAttributes = () => _config.deviceAttributes; + final enquiry = _config.enquiryResponse; + _terminal.onEnquiry = enquiry.isEmpty + ? null + : () => .fromList(utf8.encode(enquiry)); + } +} diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index 85ba6efa..e98c0a9f 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -1,11 +1,10 @@ -export 'foundation/callbacks.dart'; export 'foundation/cell_metrics.dart'; export 'foundation/cell_range.dart'; 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_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/surface_geometry.dart b/packages/flterm/lib/src/foundation/surface_geometry.dart new file mode 100644 index 00000000..e8eeb668 --- /dev/null +++ b/packages/flterm/lib/src/foundation/surface_geometry.dart @@ -0,0 +1,247 @@ +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 SurfaceGeometry { + /// 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 SurfaceGeometry._({ + 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 SurfaceGeometry && + 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 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 = 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 || + paddingRightPx == null || + paddingTopPx == null || + paddingBottomPx == null) { + return null; + } + + final screenWidth = + measurement.cols * cellWidthPx + paddingLeftPx + paddingRightPx; + final screenHeight = + measurement.rows * cellHeightPx + paddingTopPx + paddingBottomPx; + if (screenWidth > _maxMouseDimension || screenHeight > _maxMouseDimension) { + return null; + } + + 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, + 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 [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 SurfaceMeasurement { + /// Number of measured terminal columns. + final int cols; + + /// Number of measured terminal rows. + final int rows; + + /// Logical width of one terminal cell. + final double cellWidth; + + /// Logical height of one terminal cell. + final double cellHeight; + + /// Logical padding before the grid's horizontal origin. + final double paddingLeft; + + /// Logical padding after the grid's horizontal extent. + final double paddingRight; + + /// Logical padding before the grid's vertical origin. + final double paddingTop; + + /// Logical padding after the grid's vertical extent. + final double paddingBottom; + + /// Number of physical pixels represented by one logical pixel. + final double devicePixelRatio; + + const SurfaceMeasurement({ + 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 SurfaceMeasurement && + 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_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_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/foundation/terminal_theme.dart b/packages/flterm/lib/src/foundation/terminal_theme.dart index 6eb7a941..3e00fdf1 100644 --- a/packages/flterm/lib/src/foundation/terminal_theme.dart +++ b/packages/flterm/lib/src/foundation/terminal_theme.dart @@ -27,6 +27,8 @@ const _darkAnsiColors = [ ]; const _defaultFontFamilyFallback = [ + 'Symbols Nerd Font', + 'Symbols Nerd Font Mono', 'JetBrains Mono', 'Menlo', 'Consolas', diff --git a/packages/flterm/lib/src/input/input_encoder.dart b/packages/flterm/lib/src/input/input_encoder.dart new file mode 100644 index 00000000..4590fa87 --- /dev/null +++ b/packages/flterm/lib/src/input/input_encoder.dart @@ -0,0 +1,121 @@ +import 'package:libghostty/libghostty.dart'; + +import '../foundation.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 InputEncoder { + final Terminal _terminal; + final _keyEvent = KeyEvent(); + final _mouseEvent = MouseEvent(); + final _keyEncoder = KeyEncoder(); + final _mouseEncoder = MouseEncoder(); + + InputEncoder(this._terminal); + + void dispose() { + _keyEvent.dispose(); + _mouseEvent.dispose(); + _keyEncoder.dispose(); + _mouseEncoder.dispose(); + } + + 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; + 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(MouseInput input, {required SurfaceGeometry? geometry}) { + _mouseEvent + ..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: input.anyButtonPressed); + return _mouseEncoder.encode(_mouseEvent); + } + + String encodeScrollButton( + ScrollInput input, { + required MouseButton button, + required SurfaceGeometry? geometry, + }) { + var x = input.pixelX; + var y = input.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 = input.mods; + _setMousePosition(x, y, geometry); + _mouseEncoder.sync(_terminal); + _mouseEncoder.setAnyButtonPressed(pressed: false); + return _mouseEncoder.encode(_mouseEvent); + } + + void updateGeometry(SurfaceGeometry 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, SurfaceGeometry? 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/input_message.dart b/packages/flterm/lib/src/input/input_message.dart new file mode 100644 index 00000000..58197746 --- /dev/null +++ b/packages/flterm/lib/src/input/input_message.dart @@ -0,0 +1,107 @@ +import 'package:libghostty/libghostty.dart'; + +/// The outcome of routing normalized keyboard input. +enum KeyDisposition { + /// 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 KeyInput { + /// 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 KeyInput({ + 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 MouseInput { + /// 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 MouseInput({ + 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 ScrollInput { + /// 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; + + /// Signed vertical cell steps. Negative values scroll up. + final int vertical; + + /// 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.vertical, + required this.horizontal, + required this.reportMouse, + }); +} diff --git a/packages/flterm/lib/src/input/interaction_region.dart b/packages/flterm/lib/src/input/interaction_region.dart new file mode 100644 index 00000000..25b29700 --- /dev/null +++ b/packages/flterm/lib/src/input/interaction_region.dart @@ -0,0 +1,710 @@ +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/selection_session.dart'; +import '../links/link_interaction.dart'; +import '../links/link_settings.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 [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 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 ScrollController? scrollController; + final ValueChanged? onLinkActivate; + + const InteractionRegion({ + 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() => _InteractionRegionState(); +} + +final class _InteractionRegionState 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; + + ViewAttachment 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: ScrollGestureRegion( + metrics: widget.metrics, + attachment: widget.attachment, + physics: widget.scrollPhysics, + interaction: widget.interaction, + onScrollStart: _handleScrollStart, + child: PrimitiveGestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onDragStart: _handleDragStart, + onDragUpdate: _handleDragUpdate, + onDragEnd: _endDrag, + onLongPressStart: _handleLongPressStart, + onLongPressMoveUpdate: _handleLongPressMoveUpdate, + onLongPressUp: _endDrag, + child: widget.child, + ), + ), + ); + } + + @override + void didUpdateWidget(InteractionRegion 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( + SelectionAutoscrollInput( + 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( + ViewAttachment 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( + SelectionPressInput( + 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, { + ViewAttachment? 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(ViewAttachment 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, + ViewAttachment? attachment, + }) { + final target = attachment ?? _attachment; + target.handleMouseEvent( + MouseInput( + 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( + SelectionDragInput( + cell: clampedCell, + pixelX: position.dx, + pixelY: position.dy, + rectangle: rectangle, + ), + ); + } + + void _updateMouseButtons( + _TrackedPointer pointer, + int buttons, + Offset position, { + ViewAttachment? 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, { + ViewAttachment? 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/keyboard_input_adapter.dart b/packages/flterm/lib/src/input/keyboard_input_adapter.dart new file mode 100644 index 00000000..9a57640d --- /dev/null +++ b/packages/flterm/lib/src/input/keyboard_input_adapter.dart @@ -0,0 +1,266 @@ +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 'input_message.dart'; +import 'text_input_session.dart'; + +/// Coordinates hardware-key and platform text-input handling for a +/// [TerminalView]. +/// +/// 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 KeyboardInputAdapter extends ChangeNotifier { + static const _space = 0x20; + static const _delete = 0x7f; + static const _macFunctionKeyStart = 0xF700; + static const _macFunctionKeyEnd = 0xF8FF; + + final TerminalControllerImpl _controller; + final _textInput = TextInputSession(); + FocusNode? _focusNode; + Brightness _keyboardAppearance = .dark; + var _preeditText = ''; + var _wasFocused = false; + + KeyboardInputAdapter(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 action = switch (event) { + KeyDownEvent() => KeyAction.press, + KeyUpEvent() => KeyAction.release, + KeyRepeatEvent() => KeyAction.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 Mods.ctrl() : mods; + final composing = + _textInput.hasActiveComposition || _preeditText.isNotEmpty; + final input = KeyInput( + 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(KeyInput input) { + return input.composing && + _textInput.isAttached && + _isDesktopPlatform && + !_shouldRouteToTextInput(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; + final mods = input.mods; + if (mods.hasShift || mods.hasCtrl || mods.hasAlt || mods.hasSuper) { + return false; + } + return _textInput.consumeCommittedCompositionEdit(); + } + + 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; + 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_raw_gesture_detector.dart b/packages/flterm/lib/src/input/primitive_gesture_detector.dart similarity index 54% rename from packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart rename to packages/flterm/lib/src/input/primitive_gesture_detector.dart index 9123770c..66e5b111 100644 --- a/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart +++ b/packages/flterm/lib/src/input/primitive_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 PrimitiveGestureDetector 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; @@ -42,7 +36,7 @@ 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, @@ -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/scroll_gesture_region.dart b/packages/flterm/lib/src/input/scroll_gesture_region.dart new file mode 100644 index 00000000..6ac7c484 --- /dev/null +++ b/packages/flterm/lib/src/input/scroll_gesture_region.dart @@ -0,0 +1,666 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, 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/view_attachment.dart'; +import 'input_message.dart'; + +typedef _ScrollTarget = ({Offset position, Mods mods, bool reportMouse}); + +/// 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 ScrollGestureRegion extends StatefulWidget { + final Widget child; + final CellMetrics metrics; + final ScrollPhysics physics; + final ViewAttachment attachment; + final ViewInteractionState interaction; + final ValueChanged onScrollStart; + + const ScrollGestureRegion({ + super.key, + required this.metrics, + required this.physics, + required this.attachment, + required this.interaction, + required this.onScrollStart, + required this.child, + }); + + @override + State createState() => _ScrollGestureRegionState(); +} + +final class _ScrollGestureRegionState 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: { + _TwoAxisScrollRecognizer: + GestureRecognizerFactoryWithHandlers<_TwoAxisScrollRecognizer>( + () => _TwoAxisScrollRecognizer(debugOwner: this), + (recognizer) => _configure(recognizer, .pan), + ), + _VerticalScrollRecognizer: + GestureRecognizerFactoryWithHandlers<_VerticalScrollRecognizer>( + () => _VerticalScrollRecognizer(debugOwner: this), + (recognizer) => _configure(recognizer, .vertical), + ), + }, + child: widget.child, + ), + ); + } + + @override + void didUpdateWidget(ScrollGestureRegion 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 _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 _ScrollSequence).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); + } + + 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 _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(); + } + + _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) { + 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); + } + + Offset _normalizePointerScroll(PointerScrollEvent event) { + if (kIsWeb || + defaultTargetPlatform != TargetPlatform.macOS || + event.kind != PointerDeviceKind.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, + ); + } + + void _reset() { + _cancelGesture(); + _remainder = null; + } + + 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( + ScrollInput( + horizontal: horizontal, + mods: target.mods, + pixelX: target.position.dx, + pixelY: target.position.dy, + reportMouse: target.reportMouse, + vertical: vertical, + ), + ); + } + + void _stopBallistic() { + _ticker.stop(); + final activity = _activity; + if (activity == null) return; + if (activity.isDragging) { + activity.dropMomentum(); + } else { + _activity = null; + } + } + + _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); + } +} + +/// 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) == Offset.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; + } +} + +/// Per-axis motion state matching Flutter's scroll-drag momentum behavior. +final class _ScrollAxis { + 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 applyMomentumTo(double replacement) { + if (!_retainsMomentum || + replacement.sign != carriedVelocity.sign || + replacement.abs() <= + carriedVelocity.abs() * + ScrollDragController.momentumRetainVelocityThresholdFactor) { + return replacement; + } + return replacement + carriedVelocity; + } + + 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( + // 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, + viewportDimension: viewportDimension > 0 ? viewportDimension : 1, + axisDirection: axis == .horizontal ? .right : .down, + 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 _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; + } + } +} + +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 + void addAllowedPointer(PointerDownEvent event) { + startSequence(event); + super.addAllowedPointer(event); + } + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + startSequence(event); + super.addAllowedPointerPanZoom(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + super.didStopTrackingLastPointer(pointer); + stopSequence(); + } + + @override + bool isPointerAllowed(PointerEvent event) { + return allowsSequence(event) && super.isPointerAllowed(event); + } + + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { + return allowsSequence(event) && super.isPointerPanZoomAllowed(event); + } +} + +final class _VerticalScrollRecognizer extends VerticalDragGestureRecognizer + with _ScrollSequence { + _VerticalScrollRecognizer({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 + void didStopTrackingLastPointer(int pointer) { + super.didStopTrackingLastPointer(pointer); + stopSequence(); + } + + @override + bool isPointerAllowed(PointerEvent event) { + return allowsSequence(event) && super.isPointerAllowed(event); + } + + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) { + return allowsSequence(event) && super.isPointerPanZoomAllowed(event); + } +} diff --git a/packages/flterm/lib/src/widgets/terminal_input_client.dart b/packages/flterm/lib/src/input/text_input_session.dart similarity index 80% rename from packages/flterm/lib/src/widgets/terminal_input_client.dart rename to packages/flterm/lib/src/input/text_input_session.dart index 275f9448..027d6a0c 100644 --- a/packages/flterm/lib/src/widgets/terminal_input_client.dart +++ b/packages/flterm/lib/src/input/text_input_session.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])$'); +final class TextInputSession with DeltaTextInputClient { 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 @@ -200,7 +201,20 @@ final class TerminalInputClient with DeltaTextInputClient { _committedCompositionEdit == .suppressNextDeletionDelta; var fromCompositionInBatch = _hadVisiblePreeditText || hasActiveComposition; for (final delta in deltas) { - _value = delta.apply(_value); + final TextEditingValue next; + try { + next = delta.apply(_value); + } on Object { + // Some platforms (notably iOS) keep their own marked-text buffer and + // ignore the sentinel we push via setEditingState after each commit. + // A later delta then references offsets past our sentinel value, so + // applying it throws. Left uncaught, that kills the text-input channel + // and freezes all further typing (e.g. after composing an accent). + // Resync the platform back to the sentinel and drop this batch instead. + _recoverFromDesync(); + return; + } + _value = next; _processDelta(delta, fromCompositionInBatch: fromCompositionInBatch); fromCompositionInBatch = fromCompositionInBatch || _hadVisiblePreeditText; } @@ -243,8 +257,8 @@ final class TerminalInputClient with DeltaTextInputClient { void _clearNewlineActionSuppression() { _newlineActionDedupeTimer?.cancel(); _newlineActionDedupeTimer = null; - _suppressNextNewlineDelta = false; - _suppressNextNewlineAction = false; + _newlineActionsToSuppress = 0; + _newlineDeltasToSuppress = 0; } void _closeConnection() { @@ -271,13 +285,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 +320,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 +369,7 @@ final class TerminalInputClient with DeltaTextInputClient { return; } final count = delta.deletedRange.end - delta.deletedRange.start; + _clearNewlineActionSuppression(); _onDelete?.call(count); _clearCommittedCompositionEdit(); _resetBuffer(); @@ -377,6 +415,20 @@ final class TerminalInputClient with DeltaTextInputClient { } } + /// Resynchronizes after a delta could not be applied to the sentinel value. + /// + /// Clears any composition/newline bookkeeping and pushes the sentinel back to + /// the platform so subsequent input maps to a known state again, keeping the + /// text-input channel alive instead of letting the failure freeze typing. + void _recoverFromDesync() { + _clearNewlineActionSuppression(); + _clearCommittedCompositionEdit(); + final hadVisiblePreeditText = _hadVisiblePreeditText; + _hadVisiblePreeditText = false; + if (hadVisiblePreeditText) _onPreeditChanged?.call(''); + _resetBuffer(); + } + void _resetInputState() { final hadVisiblePreeditText = _hadVisiblePreeditText || _value.terminalComposingText.isNotEmpty; @@ -386,22 +438,12 @@ 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 } /// 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 && @@ -423,14 +465,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/widgets/selection_gesture_driver.dart b/packages/flterm/lib/src/interaction/selection_gesture_driver.dart similarity index 66% rename from packages/flterm/lib/src/widgets/selection_gesture_driver.dart rename to packages/flterm/lib/src/interaction/selection_gesture_driver.dart index 1dd17743..c97f110b 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); } @@ -98,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/lib/src/interaction/selection_session.dart b/packages/flterm/lib/src/interaction/selection_session.dart new file mode 100644 index 00000000..58a28bc0 --- /dev/null +++ b/packages/flterm/lib/src/interaction/selection_session.dart @@ -0,0 +1,361 @@ +import 'package:libghostty/libghostty.dart'; +import 'package:meta/meta.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 +/// 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 SelectionSession { + 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; + + SelectionSession(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(SelectionAutoscrollInput 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(SelectionDragInput 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(SelectionPressInput 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(SurfaceGeometry 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); + } +} 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/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 38932873..a055aefa 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/frame_source.dart'; export 'rendering/terminal_renderer.dart'; diff --git a/packages/flterm/lib/src/rendering/atlas/atlas.dart b/packages/flterm/lib/src/rendering/atlas/atlas.dart index 9049f15b..3d5d5ce0 100644 --- a/packages/flterm/lib/src/rendering/atlas/atlas.dart +++ b/packages/flterm/lib/src/rendering/atlas/atlas.dart @@ -81,7 +81,14 @@ class Atlas { required bool bold, required bool italic, int span = 1, - }) => _cache.addCodepoint(codepoint, bold: bold, italic: italic, span: span); + bool centerInFirstCell = false, + }) => _cache.addCodepoint( + codepoint, + bold: bold, + italic: italic, + span: span, + centerInFirstCell: centerInFirstCell, + ); /// Returns or creates a decoration sprite for the given underline [style]. AtlasEntry addDecoration(UnderlineStyle style) => _cache.addDecoration(style); diff --git a/packages/flterm/lib/src/rendering/atlas/atlas_cache.dart b/packages/flterm/lib/src/rendering/atlas/atlas_cache.dart index 1394ee39..a793816a 100644 --- a/packages/flterm/lib/src/rendering/atlas/atlas_cache.dart +++ b/packages/flterm/lib/src/rendering/atlas/atlas_cache.dart @@ -10,12 +10,19 @@ import 'lanes/text_lane.dart'; /// Lookup key for a cached text glyph. Two glyphs with the same text, bold, /// and italic state share the same atlas entry. typedef TextAtlasKey = ({String text, bool bold, bool italic}); -typedef _CodepointKey = ({int codepoint, bool bold, bool italic, int span}); +typedef _CodepointKey = ({ + int codepoint, + bool bold, + bool italic, + int span, + bool centerInFirstCell, +}); typedef _TextKey = ({ String text, bool bold, bool italic, int span, + bool centerInFirstCell, double sourcePadding, }); typedef _SpriteKey = ({int codepoint, int span}); @@ -75,6 +82,7 @@ class AtlasCache { required bool bold, required bool italic, int span = 1, + bool centerInFirstCell = false, }) { if (span == 1 && !bold && @@ -94,15 +102,21 @@ class AtlasCache { if (sprite != null) return sprite; } - final key = (codepoint: codepoint, bold: bold, italic: italic, span: span); + final key = ( + codepoint: codepoint, + bold: bold, + italic: italic, + span: span, + centerInFirstCell: centerInFirstCell, + ); final existing = _codepoints[key]; if (existing != null) return existing; - final entry = _addText(( - text: String.fromCharCode(codepoint), - bold: bold, - italic: italic, - ), span: span); + final entry = _addText( + (text: String.fromCharCode(codepoint), bold: bold, italic: italic), + span: span, + centerInFirstCell: centerInFirstCell, + ); _codepoints[key] = entry; return entry; } @@ -142,6 +156,7 @@ class AtlasCache { bold: key.bold, italic: key.italic, span: span, + centerInFirstCell: false, sourcePadding: 0.0, ); return _emoji[cacheKey] ??= _emojiLane.rasterizeEmoji( @@ -160,13 +175,18 @@ class AtlasCache { return _sprites[key] ??= _spriteLane.rasterizeSprite(glyph, span: span); } - AtlasEntry _addText(TextAtlasKey key, {int span = 1}) { + AtlasEntry _addText( + TextAtlasKey key, { + int span = 1, + bool centerInFirstCell = false, + }) { final sourcePadding = _textSourcePadding(key.text, span: span); final cacheKey = ( text: key.text, bold: key.bold, italic: key.italic, span: span, + centerInFirstCell: centerInFirstCell, sourcePadding: sourcePadding, ); return _text[cacheKey] ??= _textLane.rasterizeText( @@ -174,6 +194,7 @@ class AtlasCache { bold: key.bold, italic: key.italic, span: span, + centerInFirstCell: centerInFirstCell, sourcePadding: sourcePadding, ); } diff --git a/packages/flterm/lib/src/rendering/atlas/lanes/emoji_lane.dart b/packages/flterm/lib/src/rendering/atlas/lanes/emoji_lane.dart index b5aeea24..c560148c 100644 --- a/packages/flterm/lib/src/rendering/atlas/lanes/emoji_lane.dart +++ b/packages/flterm/lib/src/rendering/atlas/lanes/emoji_lane.dart @@ -42,6 +42,7 @@ class EmojiLane extends ParagraphLane { Paragraph paragraph, AtlasEntry entry, double _, + double _, Offset _, ) { final cellWidth = entry.srcRight - entry.srcLeft; diff --git a/packages/flterm/lib/src/rendering/atlas/lanes/paragraph_lane.dart b/packages/flterm/lib/src/rendering/atlas/lanes/paragraph_lane.dart index d92df448..a7a2a569 100644 --- a/packages/flterm/lib/src/rendering/atlas/lanes/paragraph_lane.dart +++ b/packages/flterm/lib/src/rendering/atlas/lanes/paragraph_lane.dart @@ -11,6 +11,7 @@ typedef _PendingParagraph = ({ Paragraph paragraph, AtlasEntry entry, double widthScale, + double heightScale, Offset paintOffset, }); @@ -46,12 +47,14 @@ abstract class ParagraphLane extends AtlasLane { Paragraph paragraph, AtlasEntry entry, { double widthScale = 1.0, + double heightScale = 1.0, Offset paintOffset = Offset.zero, }) { _pending.add(( paragraph: paragraph, entry: entry, widthScale: widthScale, + heightScale: heightScale, paintOffset: paintOffset, )); } @@ -136,6 +139,7 @@ abstract class ParagraphLane extends AtlasLane { pending.paragraph, entry, pending.widthScale, + pending.heightScale, pending.paintOffset, ); canvas.restore(); @@ -150,6 +154,7 @@ abstract class ParagraphLane extends AtlasLane { Paragraph paragraph, AtlasEntry entry, double widthScale, + double heightScale, Offset paintOffset, ); } diff --git a/packages/flterm/lib/src/rendering/atlas/lanes/text_lane.dart b/packages/flterm/lib/src/rendering/atlas/lanes/text_lane.dart index 5bbc54ff..5bff66d2 100644 --- a/packages/flterm/lib/src/rendering/atlas/lanes/text_lane.dart +++ b/packages/flterm/lib/src/rendering/atlas/lanes/text_lane.dart @@ -1,3 +1,4 @@ +import 'dart:math' show min; import 'dart:ui'; import '../atlas_entry.dart'; @@ -13,17 +14,18 @@ class TextLane extends ParagraphLane { Paragraph paragraph, AtlasEntry entry, double widthScale, + double heightScale, Offset paintOffset, ) { final offset = Offset( entry.srcLeft + paintOffset.dx, entry.srcTop + paintOffset.dy, ); - if (widthScale == 1.0) { + if (widthScale == 1.0 && heightScale == 1.0) { canvas.drawParagraph(paragraph, offset); } else { canvas.translate(offset.dx, offset.dy); - canvas.scale(widthScale, 1.0); + canvas.scale(widthScale, heightScale); canvas.drawParagraph(paragraph, Offset.zero); } } @@ -39,6 +41,7 @@ class TextLane extends ParagraphLane { required bool bold, required bool italic, int span = 1, + bool centerInFirstCell = false, double sourcePadding = 0.0, }) { final pxCellWidth = (this.pxCellWidth * span).ceil().toDouble(); @@ -57,14 +60,34 @@ class TextLane extends ParagraphLane { width: double.infinity, ); + final isSingleCodepoint = + text.length == 1 || + (text.length == 2 && + text.codeUnitAt(0) >= 0xD800 && + text.codeUnitAt(0) <= 0xDBFF && + text.codeUnitAt(1) >= 0xDC00 && + text.codeUnitAt(1) <= 0xDFFF); final textWidth = paragraph.maxIntrinsicWidth; - final widthScale = span > 1 && textWidth > pxCellWidth + final fitsConstrainedGlyph = + span > 1 || (isSingleCodepoint && text.runes.first >= 0x80); + final widthConstraint = fitsConstrainedGlyph && textWidth > pxCellWidth ? pxCellWidth / textWidth : 1.0; - final bearingX = span > 1 && textWidth > 0.0 && textWidth < pxCellWidth - ? (pxCellWidth - textWidth) / 2 + final heightConstraint = + centerInFirstCell && isSingleCodepoint && paragraph.height > pxHeight + ? pxHeight / paragraph.height + : 1.0; + // Oversized fallback symbols must fit without distorting their shape. + final widthScale = isSingleCodepoint + ? min(widthConstraint, heightConstraint) + : widthConstraint; + final heightScale = isSingleCodepoint ? widthScale : 1.0; + final alignmentWidth = centerInFirstCell ? this.pxCellWidth : pxCellWidth; + final paintedWidth = textWidth * widthScale; + final bearingX = span > 1 && paintedWidth > 0.0 + ? ((alignmentWidth - paintedWidth) / 2).clamp(0.0, double.infinity) : 0.0; - final bearingY = pxBaseline - paragraph.alphabeticBaseline; + final bearingY = pxBaseline - paragraph.alphabeticBaseline * heightScale; final paintOffset = Offset( sourcePadding + bearingX, sourcePadding + bearingY, @@ -86,6 +109,7 @@ class TextLane extends ParagraphLane { paragraph, entry, widthScale: widthScale, + heightScale: heightScale, paintOffset: paintOffset, ); return entry; 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/cell_content_resolver.dart b/packages/flterm/lib/src/rendering/cell_content_resolver.dart index 2fd0ec96..2fcc556e 100644 --- a/packages/flterm/lib/src/rendering/cell_content_resolver.dart +++ b/packages/flterm/lib/src/rendering/cell_content_resolver.dart @@ -12,6 +12,7 @@ final class CellContentResolver { final Atlas _atlas; var _lastCodepoint = -1; var _lastSpan = 0; + var _lastCenterInFirstCell = false; var _lastBold = false; var _lastItalic = false; late AtlasEntry _lastEntry; @@ -22,11 +23,20 @@ final class CellContentResolver { CellIterator cell, { required Style style, required int span, + bool borrowedCell = false, }) { final graphemeLength = cell.graphemeLength; if (graphemeLength == 0) return null; final codepoint = cell.codepoint; + if (borrowedCell && graphemeLength == 1) { + return resolveCodepoint( + codepoint, + style: style, + span: span, + centerInFirstCell: true, + ); + } if (graphemeLength == 1) { if (codepoint == 0x20) return null; if (_usesCodepointEntry( @@ -78,9 +88,11 @@ final class CellContentResolver { int codepoint, { required Style style, int span = 1, + bool centerInFirstCell = false, }) { if (codepoint == _lastCodepoint && span == _lastSpan && + centerInFirstCell == _lastCenterInFirstCell && style.bold == _lastBold && style.italic == _lastItalic) { return _lastEntry; @@ -91,9 +103,11 @@ final class CellContentResolver { bold: style.bold, italic: style.italic, span: span, + centerInFirstCell: centerInFirstCell, ); _lastCodepoint = codepoint; _lastSpan = span; + _lastCenterInFirstCell = centerInFirstCell; _lastBold = style.bold; _lastItalic = style.italic; _lastEntry = entry; diff --git a/packages/flterm/lib/src/rendering/terminal_frame_builder.dart b/packages/flterm/lib/src/rendering/frame_builder.dart similarity index 88% rename from packages/flterm/lib/src/rendering/terminal_frame_builder.dart rename to packages/flterm/lib/src/rendering/frame_builder.dart index 3f38a75d..61f19bd4 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, @@ -246,7 +246,7 @@ class TerminalFrameBuilder { if (terminalDirty) { if (dirty != .clean || hasDirtyRows) { _build(dirty == .clean ? .partial : dirty); - _renderState.dirty = .clean; + _renderState.clean(); } } else if (hasDirtyRows) { _build(.partial); @@ -257,16 +257,26 @@ class TerminalFrameBuilder { _rowBuilder.beginFrame(); final rebuildAll = dirty != DirtyState.partial; + final useDirtyIterator = + dirty == _renderState.dirty && + dirty != .clean && + (dirty != .partial || !_dirtyRows.anyDirty); _rows.reset(_renderState); - var row = 0; - while (_rows.next()) { - if (row >= _state.rows) break; - if (rebuildAll || _rows.dirty || _dirtyRows.isDirty(row)) { + if (useDirtyIterator) { + while (_rows.nextDirty()) { + final row = _rows.index; + if (row >= _state.rows) break; _rowBuilder.rebuildRow(row, _rows, _cells); - _rows.dirty = false; } - row++; + } else { + while (_rows.next()) { + final row = _rows.index; + if (row >= _state.rows) break; + if (rebuildAll || _rows.dirty || _dirtyRows.isDirty(row)) { + _rowBuilder.rebuildRow(row, _rows, _cells); + } + } } _dirtyRows._clear(); @@ -314,9 +324,9 @@ 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(); + var _cursor = const RenderStateCursor(); _CursorCellSnapshot? _lastCell; _CursorFrameBuilder(this._state, this._content); @@ -350,27 +360,29 @@ final class _CursorFrameBuilder { final cursor = renderState.cursor; final scrollbackLen = scrollbar.total - scrollbar.visible; final inViewport = + cursor.viewportHasValue && cursor.visible && (scrollbackLen <= 0 || scrollbar.offset >= scrollbackLen) && - cursor.position.row >= 0 && - cursor.position.row < _state.rows && - cursor.position.col >= 0 && - cursor.position.col < _state.cols; + cursor.viewportY >= 0 && + cursor.viewportY < _state.rows && + cursor.viewportX >= 0 && + cursor.viewportX < _state.cols; if (!inViewport) { _hide(cursor); return; } - final adjustedCursor = cursor.wideTail && cursor.position.col > 0 - ? cursor.copyWith( - position: cursor.position.copyWith(col: cursor.position.col - 1), - ) + final adjustedCursor = cursor.wideTail && cursor.viewportX > 0 + ? cursor.copyWith(viewportX: cursor.viewportX - 1) : cursor; - final effectiveCursor = adjustedCursor.shape == CursorShape.block - ? adjustedCursor.copyWith(shape: _state.theme.cursor.shape) + final effectiveCursor = adjustedCursor.visualStyle == CursorShape.block + ? adjustedCursor.copyWith(visualStyle: _state.theme.cursor.shape) : adjustedCursor; - final ref = GridRef.at(terminal, effectiveCursor.position); + final ref = GridRef.at( + terminal, + Position(row: effectiveCursor.viewportY, col: effectiveCursor.viewportX), + ); final cell = _CursorCellSnapshot(ref.content, ref.style, wide: ref.isWide); _cursor = effectiveCursor; @@ -381,7 +393,7 @@ final class _CursorFrameBuilder { refreshGlyph(); } - void _hide(Cursor cursor) { + void _hide(RenderStateCursor cursor) { _cursor = cursor; _lastCell = null; _state.cursor = cursor.copyWith(visible: false); @@ -416,7 +428,7 @@ final class _CursorFrameBuilder { final cell = _lastCell; if (cell == null || !_state.cursorFocused || - _cursor.shape != CursorShape.block) { + _cursor.visualStyle != CursorShape.block) { return null; } final style = cell.style; @@ -439,9 +451,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; @@ -453,7 +465,12 @@ final class _ForegroundEmitter { _ForegroundEmitter(this._sprites, this._content, this._frame, this._state) : _operators = _AsciiOperatorRun(); - void emit(CellIterator cell, _RowBuildState row, {required int span}) { + void emit( + CellIterator cell, + _RowBuildState row, { + required int span, + required int glyphSpan, + }) { if (!cell.hasText) { flush(row); return; @@ -474,9 +491,14 @@ final class _ForegroundEmitter { } flush(row); - final entry = _content.resolveCell(cell, style: style, span: span); + final entry = _content.resolveCell( + cell, + style: style, + span: glyphSpan, + borrowedCell: glyphSpan != span, + ); if (entry == null) return; - _emitEntry(entry, row, x: row.spriteX, wideText: span == 2); + _emitEntry(entry, row, x: row.spriteX, wideText: glyphSpan == 2); } void emitPreedit( @@ -657,6 +679,7 @@ final class _ForegroundEmitter { fontWeight: bold ? .bold : theme.fontWeight, fontStyle: italic ? .italic : .normal, fontFamilyFallback: theme.fontFamilyFallback, + fontFeatures: const [FontFeature.enable('liga')], ); _lastTextStyleTheme = theme; _lastTextStyleForeground = foreground; @@ -689,7 +712,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; @@ -767,15 +790,16 @@ final class _PreeditRange { static _PreeditRange? resolve({ required String text, - required Cursor cursor, + required RenderStateCursor cursor, required int rows, required int cols, }) { - if (!cursor.visible || - cursor.position.row < 0 || - cursor.position.row >= rows || - cursor.position.col < 0 || - cursor.position.col >= cols || + if (!cursor.viewportHasValue || + !cursor.visible || + cursor.viewportY < 0 || + cursor.viewportY >= rows || + cursor.viewportX < 0 || + cursor.viewportX >= cols || rows <= 0 || cols <= 0) { return null; @@ -784,14 +808,14 @@ final class _PreeditRange { final preedit = _PreeditText.parse(text); if (preedit.isEmpty) return null; - final startCol = preedit.startCol(cursor.position.col, cols); + final startCol = preedit.startCol(cursor.viewportX, cols); final visible = preedit.visibleSuffix(cols - startCol); final visibleWidth = visible.width; final endCol = startCol + visibleWidth; if (startCol >= endCol) return null; return _PreeditRange( - row: cursor.position.row, + row: cursor.viewportY, startCol: startCol, endCol: endCol, clusterOffset: visible.clusterOffset, @@ -891,6 +915,7 @@ final class _RowBuildState { var bgRunInverse = false; var bgRunExplicit = false; var preeditEmitted = false; + var previousSymbol = false; void advance(int span, double cellWidth) { col += span; @@ -922,6 +947,7 @@ final class _RowBuildState { bgRunInverse = false; bgRunExplicit = false; preeditEmitted = false; + previousSymbol = false; } } @@ -930,7 +956,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 +1135,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 +1148,7 @@ final class _TerminalRowBuilder { LinkSnapshot linkSnapshot = .empty; var _hasLinks = false; - _TerminalRowBuilder({ + _RowBuilder({ required this._atlas, required this._sprites, required this._state, @@ -1157,7 +1183,7 @@ final class _TerminalRowBuilder { ({int? previous, int? current}) updatePreedit( String text, { - required Cursor cursor, + required RenderStateCursor cursor, }) { final previous = _preeditRange; final previousText = _preeditText; @@ -1311,8 +1337,10 @@ final class _TerminalRowBuilder { void _writeCell(CellIterator cell) { final row = _row; final span = cell.wide == .wide ? 2 : 1; + final glyphSpan = _glyphSpan(cell, span); final preedit = _preeditRange; if (preedit != null && preedit.overlaps(row.row, row.col, span)) { + row.previousSymbol = false; _skipPreeditCell(cell, preedit, span); return; } @@ -1347,12 +1375,13 @@ final class _TerminalRowBuilder { if (!row.hidden) { final x = row.spriteX; - _foreground.emit(cell, row, span: span); + _foreground.emit(cell, row, span: span, glyphSpan: glyphSpan); if (row.hasDecoration) { _emitDecorations(row, x: x, span: span); } } else { _foreground.flush(row); + row.previousSymbol = false; } if (span == 2) { @@ -1361,4 +1390,56 @@ final class _TerminalRowBuilder { } row.advance(span, _frame.cellWidth); } + + int _glyphSpan(CellIterator cell, int span) { + if (span > 1) { + _row.previousSymbol = false; + return span; + } + + final codepoint = cell.codepoint; + final symbol = _isSymbolCodepoint(codepoint); + final graphicsElement = _isGraphicsElement(codepoint); + final previousSymbol = _row.previousSymbol; + _row.previousSymbol = symbol && !graphicsElement; + if (!symbol || + graphicsElement || + previousSymbol || + _row.col + 1 >= _frame.cols) { + return 1; + } + + // Symbol glyphs may borrow a following blank cell without changing the + // terminal's logical cursor or decoration span. + final currentCol = cell.col; + cell.select(currentCol + 1); + final nextCodepoint = cell.codepoint; + cell.select(currentCol); + return nextCodepoint == 0 || + nextCodepoint == 0x20 || + nextCodepoint == 0x2002 + ? 2 + : 1; + } +} + +bool _isSymbolCodepoint(int codepoint) { + // Matches the symbol blocks Ghostty considers for glyph constraints. + if (codepoint < 0x2190) return false; + return (codepoint >= 0xE000 && codepoint <= 0xF8FF) || + (codepoint >= 0xF0000 && codepoint <= 0xFFFFD) || + (codepoint >= 0x100000 && codepoint <= 0x10FFFD) || + (codepoint >= 0x2190 && codepoint <= 0x21FF) || + (codepoint >= 0x2460 && codepoint <= 0x24FF) || + (codepoint >= 0x2600 && codepoint <= 0x26FF) || + (codepoint >= 0x2700 && codepoint <= 0x27BF) || + (codepoint >= 0x1F100 && codepoint <= 0x1F1FF) || + (codepoint >= 0x1F300 && codepoint <= 0x1F6FF); +} + +bool _isGraphicsElement(int codepoint) { + return (codepoint >= 0x2500 && codepoint <= 0x259F) || + (codepoint >= 0xE0B0 && codepoint <= 0xE0D7) || + (codepoint >= 0x1CC00 && codepoint <= 0x1CEBF) || + (codepoint >= 0x1FB00 && codepoint <= 0x1FBFF); } diff --git a/packages/flterm/lib/src/rendering/frame_source.dart b/packages/flterm/lib/src/rendering/frame_source.dart new file mode 100644 index 00000000..8e29b087 --- /dev/null +++ b/packages/flterm/lib/src/rendering/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 FrameSource extends ChangeNotifier { + final Terminal terminal; + final Listenable? _viewportChanges; + + FrameSource(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/kitty_image_cache.dart b/packages/flterm/lib/src/rendering/kitty_image_cache.dart index a860cdbf..bba6f6d6 100644 --- a/packages/flterm/lib/src/rendering/kitty_image_cache.dart +++ b/packages/flterm/lib/src/rendering/kitty_image_cache.dart @@ -5,6 +5,9 @@ import 'package:libghostty/libghostty.dart'; import 'package:meta/meta.dart'; /// Converts raw RGBA image bytes into a Flutter [Image]. +/// +/// The decoder may retain [pixels] until it invokes [callback], but not after. +/// The cache serializes calls and can reuse the buffer after that boundary. typedef KittyImageDecoder = void Function( Uint8List pixels, @@ -24,12 +27,22 @@ typedef KittyImageDecoder = /// Re-transmissions under the same id are detected by /// [KittyImage.generation], so same-sized replacements cannot reuse stale /// decoded images. +/// +/// Decodes are serialized. Requests retain Dart-owned source bytes while +/// queued, allowing eviction to discard obsolete animation frames before RGB +/// expansion and Flutter image decoding begin. class KittyImageCache { final VoidCallback _onImageReady; final KittyImageDecoder _decodeImage; - + final Map _requestTokens = {}; + final Map _requestedGenerations = {}; final Map _entries = {}; - final Map _generations = {}; + final Map _queuedRequests = {}; + _KittyDecodeRequest? _activeRequest; + Uint8List? _rgbSourceBuffer; + Uint8List? _rgbDecodeBuffer; + var _nextRequestToken = 0; + var _disposed = false; /// [onImageReady] fires when a pending decode completes; typically /// wired to a render box's `markNeedsPaint`. @@ -44,7 +57,13 @@ class KittyImageCache { if (entry is KittyImageReady) entry.image.dispose(); } _entries.clear(); - _generations.clear(); + _requestedGenerations.clear(); + _requestTokens.clear(); + _queuedRequests.clear(); + _activeRequest = null; + _rgbSourceBuffer = null; + _rgbDecodeBuffer = null; + _disposed = true; } /// Releases any cached entries whose id is not in [live]. @@ -52,23 +71,46 @@ class KittyImageCache { _entries.removeWhere((id, entry) { if (live.contains(id)) return false; if (entry is KittyImageReady) entry.image.dispose(); - _generations.remove(id); + _requestedGenerations.remove(id); + _requestTokens.remove(id); + _queuedRequests.remove(id); return true; }); } /// Returns the entry for [image], starting a decode on first lookup /// or when the image's generation has changed. Never blocks. - KittyImageCacheEntry lookup(KittyImage image) { - final generation = image.generation; + /// Callers that already read [KittyImage.generation] may pass it through to + /// avoid repeating the native accessor. + /// + /// A ready entry remains drawable while its replacement decodes. Only the + /// latest requested generation may replace it, so rapid updates skip stale + /// intermediate results without producing a blank frame. + KittyImageCacheEntry lookup(KittyImage image, {int? generation}) { + if (_disposed) throw StateError('KittyImageCache is disposed.'); + generation ??= image.generation; final existing = _entries[image.id]; - if (existing != null && _generations[image.id] == generation) { + if (existing != null && _requestedGenerations[image.id] == generation) { return existing; } - if (existing is KittyImageReady) existing.image.dispose(); - _entries[image.id] = KittyImagePending(); - _generations[image.id] = generation; - _beginDecode(image); + final previousGeneration = _requestedGenerations[image.id]; + _requestedGenerations[image.id] = generation; + try { + _beginDecode(image, existing); + } catch (_) { + if (previousGeneration == null) { + _requestedGenerations.remove(image.id); + } else { + _requestedGenerations[image.id] = previousGeneration; + } + _requestTokens.remove(image.id); + if (existing == null) { + _entries.remove(image.id); + } else { + _entries[image.id] = existing; + } + rethrow; + } return _entries[image.id]!; } @@ -78,69 +120,245 @@ class KittyImageCache { /// Inserts a pre-decoded [image] under [imageId]. @visibleForTesting - void putReady(int imageId, Image image) { + void putReady(int imageId, Image image, {int generation = 0}) { final existing = _entries[imageId]; if (existing is KittyImageReady) existing.image.dispose(); - _entries[imageId] = KittyImageReady(image); - _generations[imageId] = 0; + _entries[imageId] = KittyImageReady(image, generation: generation); + _requestedGenerations[imageId] = generation; + _requestTokens.remove(imageId); + _queuedRequests.remove(imageId); } - void _beginDecode(KittyImage image) { + void _beginDecode(KittyImage image, KittyImageCacheEntry? existing) { final imageId = image.id; - final generation = _generations[imageId]; - final rgba = _ensureRgba(image); - if (rgba == null) { - _entries[imageId] = KittyImageUnsupported(); + final generation = _requestedGenerations[imageId]; + final request = _captureRequest(image, generation!); + if (request == null) { + if (existing is KittyImageReady) existing.image.dispose(); + _requestTokens.remove(imageId); + _queuedRequests.remove(imageId); + _entries[imageId] = const KittyImageUnsupported(); return; } - _decodeImage(rgba, image.width, image.height, .rgba8888, (decoded) { - if (_generations[imageId] == generation && - _entries[imageId] is KittyImagePending) { - _entries[imageId] = KittyImageReady(decoded); - _onImageReady(); - } else { - decoded.dispose(); - } - }); + if (existing is! KittyImageReady) { + _entries[imageId] = const KittyImagePending(); + } + final requestToken = ++_nextRequestToken; + _requestTokens[imageId] = requestToken; + final queued = request.withToken(requestToken); + if (_activeRequest == null) { + _startDecode(queued); + } else { + // Keep native-to-Dart copies valid, but defer RGB expansion and Flutter + // decoding. Eviction can then discard superseded animation frames before + // they consume UI-isolate and raster-thread work. + // Refreshing an ID moves it behind other live work so one animated image + // cannot starve independent queued placements. + _queuedRequests + ..remove(imageId) + ..[imageId] = queued; + } } - Uint8List? _ensureRgba(KittyImage image) { + _KittyDecodeRequest? _captureRequest(KittyImage image, int generation) { if (image.compression != .none) return null; - switch (image.format) { + final format = image.format; + switch (format) { case KittyImageFormat.rgba: - return image.pixelData; case KittyImageFormat.rgb: - final src = image.pixelData; final pixelCount = image.width * image.height; - if (src.length < pixelCount * 3) return null; - final out = Uint8List(pixelCount * 4); - for (var i = 0; i < pixelCount; i++) { - out[i * 4 + 0] = src[i * 3 + 0]; - out[i * 4 + 1] = src[i * 3 + 1]; - out[i * 4 + 2] = src[i * 3 + 2]; - out[i * 4 + 3] = 0xff; - } - return out; + final bytesPerPixel = format == .rgb ? 3 : 4; + final byteLength = pixelCount * bytesPerPixel; + // A request that starts immediately consumes RGB synchronously during + // expansion. Queued requests still allocate independent snapshots so + // later terminal mutations cannot alter their pixels. + final src = format == .rgb && _activeRequest == null + ? _copyRgbPixels(image, byteLength) + : _copyPixels(image, byteLength); + if (src.length < byteLength) return null; + return _KittyDecodeRequest( + imageId: image.id, + generation: generation, + width: image.width, + height: image.height, + format: format, + pixels: src, + ); case KittyImageFormat.png: case KittyImageFormat.grayAlpha: case KittyImageFormat.gray: return null; } } + + void _completeDecode(_KittyDecodeRequest request, Image decoded) { + if (identical(_activeRequest, request)) _activeRequest = null; + try { + if (_requestedGenerations[request.imageId] == request.generation && + _requestTokens[request.imageId] == request.token) { + final previous = _entries[request.imageId]; + _requestTokens.remove(request.imageId); + _entries[request.imageId] = KittyImageReady( + decoded, + generation: request.generation, + ); + if (previous is KittyImageReady) previous.image.dispose(); + _onImageReady(); + } else { + decoded.dispose(); + } + } finally { + _startNextDecode(); + } + } + + Uint8List _copyRgbPixels(KittyImage image, int byteLength) { + final destination = _rgbSourceBuffer?.length == byteLength + ? _rgbSourceBuffer! + : Uint8List(byteLength); + final written = image.copyPixelDataInto(destination); + _rgbSourceBuffer = destination; + return written == destination.length + ? destination + : Uint8List.sublistView(destination, 0, written); + } + + Uint8List _copyPixels(KittyImage image, int byteLength) { + final destination = Uint8List(byteLength); + final written = image.copyPixelDataInto(destination); + return written == destination.length + ? destination + : Uint8List.sublistView(destination, 0, written); + } + + void _startDecode(_KittyDecodeRequest request) { + _activeRequest = request; + try { + // Only one decode is active, and its callback marks input consumption. + // Reusing this staging buffer removes one full-frame allocation without + // sharing mutable pixels between overlapping decoders. + final pixels = request.takeRgba(_rgbDecodeBuffer); + if (request.format == .rgb) _rgbDecodeBuffer = pixels; + _decodeImage( + pixels, + request.width, + request.height, + .rgba8888, + (decoded) => _completeDecode(request, decoded), + ); + } catch (_) { + if (identical(_activeRequest, request)) _activeRequest = null; + rethrow; + } + } + + void _startNextDecode() { + var decodeFailed = false; + while (_activeRequest == null && _queuedRequests.isNotEmpty) { + final imageId = _queuedRequests.keys.first; + final request = _queuedRequests.remove(imageId)!; + if (_requestedGenerations[imageId] != request.generation || + _requestTokens[imageId] != request.token) { + continue; + } + try { + _startDecode(request); + } on Object { + _requestedGenerations.remove(imageId); + _requestTokens.remove(imageId); + if (_entries[imageId] is KittyImagePending) _entries.remove(imageId); + decodeFailed = true; + } + } + if (decodeFailed) _onImageReady(); + } } /// Result of a cache lookup for a decoded image. -sealed class KittyImageCacheEntry {} +sealed class KittyImageCacheEntry { + const KittyImageCacheEntry(); +} /// A decode is in flight. A later repaint will see a [KittyImageReady]. -final class KittyImagePending extends KittyImageCacheEntry {} +final class KittyImagePending extends KittyImageCacheEntry { + const KittyImagePending(); +} /// The image is decoded and ready to draw. final class KittyImageReady extends KittyImageCacheEntry { final Image image; + final int generation; - KittyImageReady(this.image); + const KittyImageReady(this.image, {required this.generation}); } /// The image was rejected due to an unsupported format or compression. -final class KittyImageUnsupported extends KittyImageCacheEntry {} +final class KittyImageUnsupported extends KittyImageCacheEntry { + const KittyImageUnsupported(); +} + +final class _KittyDecodeRequest { + final int imageId; + final int generation; + final int width; + final int height; + final KittyImageFormat format; + Uint8List? pixels; + final int token; + + _KittyDecodeRequest({ + required this.imageId, + required this.generation, + required this.width, + required this.height, + required this.format, + required this.pixels, + this.token = 0, + }); + + Uint8List takeRgba(Uint8List? reusable) { + final source = pixels!; + pixels = null; + if (format == .rgba) return source; + final pixelCount = width * height; + final byteLength = pixelCount * 4; + final result = reusable?.length == byteLength + ? reusable! + : Uint8List(byteLength); + final rgba = Uint32List.view(result.buffer); + // Advance the RGB offset once per pixel. Recomputing `pixel * 3` for each + // channel measurably increases this per-frame conversion cost in profile. + if (Endian.host == .little) { + var sourceOffset = 0; + for (var pixel = 0; pixel < pixelCount; pixel++) { + rgba[pixel] = + source[sourceOffset] | + source[sourceOffset + 1] << 8 | + source[sourceOffset + 2] << 16 | + 0xff000000; + sourceOffset += 3; + } + } else { + var sourceOffset = 0; + for (var pixel = 0; pixel < pixelCount; pixel++) { + rgba[pixel] = + source[sourceOffset] << 24 | + source[sourceOffset + 1] << 16 | + source[sourceOffset + 2] << 8 | + 0xff; + sourceOffset += 3; + } + } + return result; + } + + _KittyDecodeRequest withToken(int token) => _KittyDecodeRequest( + imageId: imageId, + generation: generation, + width: width, + height: height, + format: format, + pixels: pixels, + token: token, + ); +} diff --git a/packages/flterm/lib/src/rendering/kitty_placement_cache.dart b/packages/flterm/lib/src/rendering/kitty_placement_cache.dart index 2b79fc68..10ab0952 100644 --- a/packages/flterm/lib/src/rendering/kitty_placement_cache.dart +++ b/packages/flterm/lib/src/rendering/kitty_placement_cache.dart @@ -12,15 +12,15 @@ 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 = {}; + final List _snapshots = []; _SnapshotKey? _key; KittyPlacementCache({required this._state, required this._images}); - /// Placement snapshots ordered by their signed z-index. + /// Placement snapshots ordered by signed z-index, then image ID. Iterable get snapshots => _snapshots; /// Refreshes snapshots from [terminal] when protocol or geometry inputs have @@ -51,9 +51,11 @@ final class KittyPlacementCache { ); if (!geometryDirty && _key == key) return false; - _clear(); + final nextSnapshots = []; + final nextLiveImageIds = {}; + var replacementPending = false; for (final placement in graphics.placements()) { - _liveImageIds.add(placement.imageId); + nextLiveImageIds.add(placement.imageId); final info = placement.renderInfo; if (!info.viewportVisible) continue; @@ -62,10 +64,15 @@ final class KittyPlacementCache { final image = graphics.image(placement.imageId); if (image == null) continue; - _images.lookup(image); - _snapshots.add( + final imageGeneration = image.generation; + final entry = _images.lookup(image, generation: imageGeneration); + replacementPending |= + entry is KittyImagePending || + (entry is KittyImageReady && entry.generation != imageGeneration); + nextSnapshots.add( KittyPlacementSnapshot( imageId: placement.imageId, + imageGeneration: imageGeneration, dst: Rect.fromLTWH( info.viewportCol * key.cellWidth + placement.xOffset / key.devicePixelRatio, @@ -85,7 +92,21 @@ final class KittyPlacementCache { ); } - if (_snapshots.length > 1) _snapshots.sort(_compareZ); + if (nextSnapshots.length > 1) nextSnapshots.sort(_compareZ); + // Animated clients often replace every image before its decode completes. + // Keep the last complete frame only while its placement geometry remains + // compatible; changed geometry must wait for matching pixels instead. + if (replacementPending && _hasCompatibleGeometry(nextSnapshots)) { + _images.evict({..._liveImageIds, ...nextLiveImageIds}); + return false; + } + + _snapshots + ..clear() + ..addAll(nextSnapshots); + _liveImageIds + ..clear() + ..addAll(nextLiveImageIds); _images.evict(_liveImageIds); _key = key; return true; @@ -96,8 +117,33 @@ final class KittyPlacementCache { _liveImageIds.clear(); } + bool _hasCompatibleGeometry(List next) { + if (_snapshots.isEmpty || _snapshots.length != next.length) return false; + final remaining = <({Rect dst, Rect src, int z}), int>{}; + for (final replacement in next) { + final geometry = ( + dst: replacement.dst, + src: replacement.src, + z: replacement.z, + ); + remaining.update(geometry, (count) => count + 1, ifAbsent: () => 1); + } + for (final previous in _snapshots) { + final geometry = (dst: previous.dst, src: previous.src, z: previous.z); + final count = remaining[geometry]; + if (count == null) return false; + if (count == 1) { + remaining.remove(geometry); + } else { + remaining[geometry] = count - 1; + } + } + return true; + } + static int _compareZ(KittyPlacementSnapshot a, KittyPlacementSnapshot b) { - return a.z.compareTo(b.z); + final z = a.z.compareTo(b.z); + return z != 0 ? z : a.imageId.compareTo(b.imageId); } } @@ -108,6 +154,12 @@ final class KittyPlacementCache { final class KittyPlacementSnapshot { final int imageId; + /// Image generation this geometry was resolved against. + /// + /// The painter only draws a decoded image with the same generation, keeping + /// replacement geometry from being paired with stale pixels. + final int imageGeneration; + /// Destination rectangle in the same logical-pixel space as cells. final Rect dst; @@ -119,6 +171,7 @@ final class KittyPlacementSnapshot { const KittyPlacementSnapshot({ required this.imageId, + this.imageGeneration = 0, required this.dst, required this.src, required this.z, 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/paint_state.dart b/packages/flterm/lib/src/rendering/paint_state.dart index 2f2f52aa..79b47a15 100644 --- a/packages/flterm/lib/src/rendering/paint_state.dart +++ b/packages/flterm/lib/src/rendering/paint_state.dart @@ -1,7 +1,8 @@ import 'dart:typed_data'; import 'dart:ui'; -import 'package:libghostty/libghostty.dart' show Cursor, TerminalColors; +import 'package:libghostty/libghostty.dart' + show RenderStateCursor, TerminalColors; import '../foundation.dart' show CellMetrics, TerminalTheme; import 'atlas/atlas.dart'; @@ -14,7 +15,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; @@ -35,7 +36,7 @@ class TerminalPaintState { var viewportOffset = 0; - var cursor = const Cursor(); + var cursor = const RenderStateCursor(); var cursorWide = false; var cursorFocused = true; var cursorColorArgb = 0xFFFFFFFF; @@ -48,7 +49,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..84f443de 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(); @@ -33,11 +33,12 @@ class CursorPainter implements TerminalPainter { void paint(Canvas canvas) { final cursor = _state.cursor; if (_state.preeditActive) return; - if (!cursor.visible || - cursor.position.row < 0 || - cursor.position.row >= _state.rows || - cursor.position.col < 0 || - cursor.position.col >= _state.cols || + if (!cursor.viewportHasValue || + !cursor.visible || + cursor.viewportY < 0 || + cursor.viewportY >= _state.rows || + cursor.viewportX < 0 || + cursor.viewportX >= _state.cols || !_state.blinkVisible) { return; } @@ -49,19 +50,22 @@ class CursorPainter implements TerminalPainter { (opacity << 24) | (_state.cursorColorArgb & 0x00FFFFFF), ); - final endCol = (cursor.position.col + (_state.cursorWide ? 2 : 1)).clamp( + final endCol = (cursor.viewportX + (_state.cursorWide ? 2 : 1)).clamp( 0, _state.cols, ); final rect = metrics.cellRangeRect( - cursor.position.row, - cursor.position.col, + cursor.viewportY, + cursor.viewportX, endCol, .zero, ); final CursorShape shape = switch (cursor.passwordInput && focused) { true => .block, - false => !focused && cursor.shape == .block ? .blockHollow : cursor.shape, + false => + !focused && cursor.visualStyle == .block + ? .blockHollow + : cursor.visualStyle, }; _paint.style = PaintingStyle.fill; @@ -99,8 +103,8 @@ class CursorPainter implements TerminalPainter { entry.srcBottom, ), Rect.fromLTWH( - cursor.position.col * metrics.cellWidth + sourceBearingX, - cursor.position.row * metrics.cellHeight + sourceBearingY, + cursor.viewportX * metrics.cellWidth + sourceBearingX, + cursor.viewportY * metrics.cellHeight + sourceBearingY, (entry.srcRight - entry.srcLeft) * inverseDpr, (entry.srcBottom - entry.srcTop) * inverseDpr, ), 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..0a1e9339 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({ @@ -34,7 +34,10 @@ class KittyGraphicsPainter implements TerminalPainter { canvas.clipRect(Rect.fromLTWH(0, 0, width, height)); for (final snap in _snapshots) { final cached = _cache.lookupById(snap.imageId); - if (cached is! KittyImageReady) continue; + if (cached is! KittyImageReady || + cached.generation != snap.imageGeneration) { + continue; + } canvas.drawImageRect(cached.image, snap.src, snap.dst, _paint); } 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 93d08820..dcf19a8e 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -6,60 +6,69 @@ 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_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. /// /// 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 FrameSource 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; - /// Internal render cache used to share compatible atlas state. - final TerminalRenderCache renderCache; + /// Device pixel ratio of the Flutter view hosting this renderer. + final double devicePixelRatio; - /// Reports viewport movement that bypasses [Terminal] listeners. - /// - /// Scrolling may change [Terminal.compressionActivity] by making previously - /// visible scrollback eligible for compression. - final VoidCallback? onViewportChanged; + /// 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; 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.renderCache, + required this.focused, + required this.atlasPool, + 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, - renderCache: renderCache, - onResize: onResize, - onViewportChanged: onViewportChanged, + surfacePadding: surfacePadding, + frameSource: frameSource, + atlasPool: atlasPool, + 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 + ..atlasPool = atlasPool ..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,54 +191,74 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// /// Created and managed by [TerminalRenderer]. Not intended for direct use. @internal -class TerminalRenderBox extends RenderBox { - Terminal _terminal; +final class TerminalRenderBox extends RenderBox { + final PaintState _paintState; + late final RenderPipeline _pipeline; + + var _applyingViewportIntent = false; + var _cellHeightPx = 0; + var _cellWidthPx = 0; + double _devicePixelRatio; + FrameSource _frameSource; + late AtlasLease _atlasLease; + var _lastCellHeight = 0.0; + var _lastCellWidth = 0.0; + var _lastDevicePixelRatio = 0.0; + var _lastScrollbackRows = 0; + var _lastSurfacePadding = EdgeInsets.zero; + LinkSnapshot _linkSnapshot; + var _needsFrameSync = false; ViewportOffset _offset; - TerminalRenderObserver _renderObserver; - OnResize? _onResize; - VoidCallback? _onViewportChanged; - TerminalRenderCache _renderCache; - late TerminalAtlasHandle _atlasHandle; + ValueChanged _onGeometryChanged; + ValueChanged _onViewportRowChanged; + int? _pendingViewportRow; var _performingLayout = false; - var _needsFrameSync = false; - var _stickToBottom = true; - var _lastScrollbackRows = 0; var _preeditText = ''; - LinkSnapshot _linkSnapshot; - - final TerminalPaintState _paintState; - late final TerminalRenderPipeline _pipeline; + bool? _primaryStickToBottom; + AtlasPool _atlasPool; + 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 this._renderCache, + required bool focused, + required this._atlasPool, + 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 = PaintState(theme, metrics) ..blinkVisible = blinkVisible - ..cursorFocused = _renderObserver.hasFocus { - _atlasHandle = _renderCache.acquireAtlas( + ..cursorFocused = focused { + _atlasLease = _atlasPool.acquireAtlas( .fromTheme( theme: theme, metrics: metrics, - devicePixelRatio: _currentDevicePixelRatio, + devicePixelRatio: _devicePixelRatio, ), ); - final atlas = _atlasHandle.atlas; - _pipeline = TerminalRenderPipeline( + final atlas = _atlasLease.atlas; + _pipeline = RenderPipeline( atlas: atlas, state: _paintState, - onImageReady: markNeedsPaint, + onImageReady: _markFrameDirty, ); + } - _applyTerminalThemeColors(); + Terminal get _terminal => _frameSource.terminal; + + set surfacePadding(EdgeInsets value) { + if (_surfacePadding == value) return; + _surfacePadding = value; + markNeedsLayout(); } bool get blinkVisible => _paintState.blinkVisible; @@ -264,15 +299,15 @@ class TerminalRenderBox extends RenderBox { final metrics = _paintState.metrics; final rows = _paintState.rows; final cols = _paintState.cols; - if (rows <= 0 || cols <= 0) { + final cursor = _paintState.cursor; + if (rows <= 0 || cols <= 0 || !cursor.viewportHasValue) { return Offset.zero & Size(metrics.cellWidth, metrics.cellHeight); } - final cursor = _paintState.cursor; - final row = cursor.position.row.clamp(0, rows - 1); - final rawCol = cursor.wideTail && cursor.position.col > 0 - ? cursor.position.col - 1 - : cursor.position.col; + final row = cursor.viewportY.clamp(0, rows - 1); + final rawCol = cursor.wideTail && cursor.viewportX > 0 + ? cursor.viewportX - 1 + : cursor.viewportX; final col = rawCol.clamp(0, cols - 1); return metrics.cellRect(Position(row: row, col: col), .zero); } @@ -315,32 +350,48 @@ class TerminalRenderBox extends RenderBox { markNeedsLayout(); } - set onResize(OnResize? value) => _onResize = value; + set onGeometryChanged(ValueChanged value) => + _onGeometryChanged = value; + + set devicePixelRatio(double value) { + if (_devicePixelRatio == value) return; + _devicePixelRatio = value; + markNeedsLayout(); + } + + set onViewportRowChanged(ValueChanged value) => + _onViewportRowChanged = value; - set onViewportChanged(VoidCallback? value) => _onViewportChanged = value; + bool get focused => _paintState.cursorFocused; - set renderObserver(TerminalRenderObserver value) { - if (_renderObserver == value) return; - if (attached) _renderObserver.removeListener(_onRenderObserverChanged); - _renderObserver = value; - if (attached) _renderObserver.addListener(_onRenderObserverChanged); - _onRenderObserverChanged(); + set focused(bool value) { + if (_paintState.cursorFocused == value) return; + _paintState.cursorFocused = value; + _pipeline.refreshCursorGlyph(); + 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 terminal(Terminal value) { - if (_terminal == value) return; - if (attached) _terminal.removeListener(_onTerminalChanged); - _terminal = value; - if (attached) _terminal.addListener(_onTerminalChanged); - _applyTerminalThemeColors(); + set frameSource(FrameSource 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(); } @@ -418,7 +460,7 @@ class TerminalRenderBox extends RenderBox { _paintState.rows = 0; _paintState.cols = 0; _pipeline.dispose(); - _atlasHandle.release(); + _atlasLease.release(); super.dispose(); } @@ -440,92 +482,93 @@ 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(); + if (geometryChanged) { + _paintState.cols = newCols; + _paintState.rows = newRows; + if (newCols > 0 && newRows > 0) { + if (gridChanged) _pipeline.configureGrid(newRows, newCols); + _onGeometryChanged( + SurfaceMeasurement( + 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; - // Grid changes invalidate every row's sprite slot layout. Atlas - // rebinding invalidates atlas references inside the pipeline. - if (gridChanged) _pipeline.markAllRowsDirty(); + _syncScrollLayout(); - if (gridChanged || atlasReconfigured) _markFrameDirty(); + // Grid changes invalidate every row's sprite slot layout. Atlas + // rebinding invalidates atlas references inside the pipeline. + if (gridChanged) _pipeline.markAllRowsDirty(); - _performingLayout = false; - } - - 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; + 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; } - 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; + } + + 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) { + 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/cursor_blink.dart b/packages/flterm/lib/src/view/cursor_blink.dart new file mode 100644 index 00000000..485953aa --- /dev/null +++ b/packages/flterm/lib/src/view/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 CursorBlink extends ValueNotifier { + Timer? _timer; + + 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/widgets/terminal_shortcut_scope.dart b/packages/flterm/lib/src/view/shortcut_scope.dart similarity index 89% rename from packages/flterm/lib/src/widgets/terminal_shortcut_scope.dart rename to packages/flterm/lib/src/view/shortcut_scope.dart index 97cdaed9..ad8bd79d 100644 --- a/packages/flterm/lib/src/widgets/terminal_shortcut_scope.dart +++ b/packages/flterm/lib/src/view/shortcut_scope.dart @@ -4,35 +4,35 @@ 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(); } /// 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 -class TerminalShortcutScope extends StatelessWidget { +final class ShortcutScope extends StatelessWidget { final Widget child; final VoidCallback? onPaste; final TerminalController controller; @@ -89,7 +89,7 @@ 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 @@ class TerminalShortcutScope extends StatelessWidget { @override Widget build(BuildContext context) { return Shortcuts( - shortcuts: {...TerminalShortcuts.defaultsFor(), ...?shortcuts}, + shortcuts: {...DefaultShortcuts.defaultsFor(), ...?shortcuts}, child: Actions( actions: >{ CopyIntent: _ConditionalAction( @@ -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_scope.dart b/packages/flterm/lib/src/view/terminal_scope.dart similarity index 55% rename from packages/flterm/lib/src/widgets/terminal_scope.dart rename to packages/flterm/lib/src/view/terminal_scope.dart index ac6f6ef8..7cb7a79f 100644 --- a/packages/flterm/lib/src/widgets/terminal_scope.dart +++ b/packages/flterm/lib/src/view/terminal_scope.dart @@ -1,17 +1,18 @@ import 'package:flutter/widgets.dart'; -import '../rendering/terminal_render_cache.dart'; +import '../rendering/atlas_pool.dart'; -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; @@ -22,34 +23,31 @@ class TerminalScope extends StatefulWidget { State createState() => _TerminalScopeState(); } -class _TerminalScopeState extends State { - final _renderCache = TerminalRenderCache(); +final class _TerminalScopeState extends State { + 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(); } } -class _TerminalScopeInherited extends InheritedWidget { - final TerminalRenderCache renderCache; +final class _TerminalScopeInherited extends InheritedWidget { + 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/widgets/terminal_scroll_controller.dart b/packages/flterm/lib/src/view/terminal_scroll_controller.dart similarity index 72% rename from packages/flterm/lib/src/widgets/terminal_scroll_controller.dart rename to packages/flterm/lib/src/view/terminal_scroll_controller.dart index 78e2c386..b9093ae9 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(); @@ -35,17 +35,17 @@ 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; } } @override - TerminalScrollPosition createScrollPosition( + ScrollPosition createScrollPosition( ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition, ) { - return TerminalScrollPosition( + return ScrollbackPosition( physics: physics, context: context, oldPosition: oldPosition, @@ -54,16 +54,16 @@ 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 ScrollbackPosition extends ScrollPositionWithSingleContext { double? _savedPixels; TerminalScreen _activeScreen; - TerminalScrollPosition({ + ScrollbackPosition({ required super.physics, required super.context, required this._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_view.dart b/packages/flterm/lib/src/view/terminal_view.dart similarity index 51% rename from packages/flterm/lib/src/widgets/terminal_view.dart rename to packages/flterm/lib/src/view/terminal_view.dart index 4060f2ac..52aedfbc 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/interaction_region.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 '../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_binding.dart'; +import '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,36 @@ 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 + with WidgetsBindingObserver { final _rendererKey = GlobalKey(); + final _links = LinkInteraction(); + final _cursorBlink = CursorBlink(); + final _mouseCursorHidden = ValueNotifier(false); + late final _mouseInteraction = Listenable.merge([_links, _mouseCursorHidden]); - Uint8List? _resolvedFontData; + late ViewAttachment _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); + final atlasPool = terminalScopeAtlasPoolOf(context); + if (atlasPool != null) return _build(atlasPool); return TerminalScope( child: Builder( - builder: (context) => - _build(context, terminalScopeRenderCacheOf(context)!), + builder: (context) => _build(terminalScopeAtlasPoolOf(context)!), ), ); } @@ -180,75 +173,110 @@ 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; _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); - 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 = ViewAttachment(_controller); + _attachment.addListener(_onControllerChanged); + _links.invalidateContent(); + } - _binding.brightness = _themeBrightness; + if (controllerChanged || focusNodeChanged || scrollControllerChanged) { + _scrollController.activeScreen = _controller.activeScreen; + _attachment.attach(_focusNode, _scrollController, viewId: _viewId!); + } + + 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 +284,12 @@ class _TerminalViewState extends State { @override void dispose() { - _blinkTimer?.cancel(); - _controller.removeListener(_onControllerChanged); - _binding.detach(); + WidgetsBinding.instance.removeObserver(this); + _cursorBlink.dispose(); + _mouseCursorHidden.dispose(); + _links.dispose(); + _attachment.removeListener(_onControllerChanged); + _attachment.dispose(); if (_ownsFocusNode) _focusNode.dispose(); _scrollController.removeListener(_onScrollChanged); if (_ownsScrollController) _scrollController.dispose(); @@ -268,94 +299,45 @@ class _TerminalViewState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); - _binding = _asBinding(_controller); - + _attachment = ViewAttachment(_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) { - 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); - - _binding.brightness = _themeBrightness; - _controller.addListener(_onControllerChanged); + _attachment.addListener(_onControllerChanged); _syncLinkInteraction(); } - Widget _build(BuildContext context, TerminalRenderCache cache) { + Widget _build(AtlasPool atlasPool) { 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, child: Focus( onKeyEvent: _handleKeyEvent, - child: TerminalShortcutScope( + child: ShortcutScope( onPaste: _handlePaste, 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, - // Under mouse tracking (TUIs like claude/vim) the gesture - // detector forwards the wheel to the app, so viewport - // scrolling is turned off to keep the two from contending - // for the same pointer signal — that contention was what - // broke the app's own scrolling. The alt screen has no - // scrollback to scroll anyway. - physics: _controller.mouseTracking != .none - ? const NeverScrollableScrollPhysics() - : 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(atlasPool, _gestureScrollPhysics), ), ), ), @@ -364,31 +346,93 @@ class _TerminalViewState extends State { ); } + Widget _buildInteraction( + AtlasPool atlasPool, + 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) => InteractionRegion( + 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, + atlasPool: atlasPool, + 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; } } @@ -396,29 +440,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 { @@ -427,16 +457,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(SurfaceMeasurement measurement) { + _attachment.handleResize(measurement); _syncLinkInteraction(); } @@ -454,17 +476,63 @@ 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 _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, ); @@ -475,20 +543,11 @@ 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 _updateGestureScrollPhysics() { + final behavior = ScrollConfiguration.of(context); + final defaultPhysics = behavior.getScrollPhysics(context); + _gestureScrollPhysics = + widget.scrollPhysics?.applyTo(defaultPhysics) ?? defaultPhysics; } void _updateTextInputGeometry() { @@ -499,43 +558,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/view_attachment.dart b/packages/flterm/lib/src/view/view_attachment.dart new file mode 100644 index 00000000..1466b3f6 --- /dev/null +++ b/packages/flterm/lib/src/view/view_attachment.dart @@ -0,0 +1,224 @@ +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/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. +/// +/// One immutable value prevents a rebuild from combining an active screen, +/// mouse mode, and alternate-scroll flag sampled from different terminal +/// notifications. +@immutable +@internal +final class ViewInteractionState { + /// The active primary or alternate screen governing the interaction. + final TerminalScreen activeScreen; + + /// The active mouse tracking mode requested by terminal content. + final MouseTracking mouseTracking; + + /// 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 ViewInteractionState({ + required this.activeScreen, + required this.mouseTracking, + required this.alternateScroll, + }); + + @override + int get hashCode => Object.hash(activeScreen, mouseTracking, alternateScroll); + + @override + 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. +/// +/// 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 ViewAttachment extends ChangeNotifier { + final Object _viewToken; + final KeyboardInputAdapter input; + final TerminalControllerImpl _controller; + late final FrameSource frameSource; + late final CompressionScheduler _compressionScheduler; + late final ValueNotifier _interaction; + ScrollController? _scrollController; + var _disposed = false; + + factory ViewAttachment(TerminalController controller) => + ViewAttachment._(controller as TerminalControllerImpl); + + ViewAttachment._(this._controller) + : _viewToken = _controller.attachView(), + input = KeyboardInputAdapter(_controller) { + frameSource = FrameSource( + 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(MouseInput event) => + _controller.handleMouseEvent(event); + + void handleResize(SurfaceMeasurement measurement) => + _controller.handleResize(measurement); + + void handleSelectionPress(SelectionPressInput event) => + _controller.handleSelectionPress(event); + + void handleSelectionRelease(Position cell) => + _controller.handleSelectionRelease(cell); + + void handleTerminalScroll(ScrollInput event) => + _controller.handleTerminalScroll(event); + + void handleViewportRowChanged(int row) { + _controller.scrollToRow(row); + _compressionScheduler.notifyActivity(); + } + + void invalidateSelection() => _controller.invalidateSelection(); + + void requestFocus() => input.requestFocus(); + + void updateSelectionAutoscroll(SelectionAutoscrollInput event) { + _controller.updateSelectionAutoscroll(event); + _compressionScheduler.notifyActivity(); + } + + void updateSelectionDrag(SelectionDragInput event) => + _controller.updateSelectionDrag(event); + + void _handleControllerChanged() { + final next = _readInteractionState(); + if (_interaction.value != next) _interaction.value = next; + notifyListeners(); + } + + void _handleTerminalChanged() => _compressionScheduler.notifyActivity(); + + ViewInteractionState _readInteractionState() { + final activeScreen = _controller.activeScreen; + return ViewInteractionState( + 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 f0fbd27a..00000000 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ /dev/null @@ -1,439 +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, MouseButton, 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, - // With mouse tracking on (TUIs like claude/vim on the alt screen), the - // wheel must be reported to the app instead of scrolling the viewport - // scrollback. Otherwise the child `Scrollable` swallows the wheel (and - // the alt screen has no scrollback), so the app's own scrolling never - // works. That `Scrollable` is disabled (`NeverScrollableScrollPhysics`) - // in this mode by `TerminalView`, so the two never contend for the same - // pointer signal. - onPointerSignal: tracked ? _handlePointerSignal : null, - // macOS delivers trackpad scrolling as pan/zoom; forward it like a wheel. - onPointerPanZoomStart: tracked ? _handlePointerPanZoomStart : null, - onPointerPanZoomUpdate: tracked ? _handlePointerPanZoomUpdate : 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, - )); - } - - /// Fractional line remainder left over while forwarding the wheel (the - /// trackpad sends small, frequent deltas; accumulating keeps the forwarded - /// scroll from running too fast). - double _wheelAccum = 0; - - /// Accumulated pan of the pan/zoom gesture in flight - /// ([PointerPanZoomUpdateEvent.pan] is cumulative since the start, so the - /// per-update delta is derived from it). - Offset _panZoomLast = Offset.zero; - - /// Wheel arriving as a [PointerSignalEvent] — a real mouse and, on macOS, the - /// synthesized trackpad scroll. Forwarded to the app as a mouse report. - void _handlePointerSignal(PointerSignalEvent event) { - if (event is! PointerScrollEvent) return; - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - // Mouse is discrete (one notch per event); trackpad is continuous, so it - // accumulates. - _forwardScroll( - event.scrollDelta.dy, - event.localPosition, - discrete: event.kind == PointerDeviceKind.mouse, - ); - } - - // On macOS the trackpad often arrives as a pan/zoom gesture rather than a - // PointerScrollEvent, so without handling it two-finger scrolling forwards - // nothing to the app. Same forwarding path as the wheel, continuous mode. - void _handlePointerPanZoomStart(PointerPanZoomStartEvent event) { - _panZoomLast = Offset.zero; - _wheelAccum = 0; - } - - void _handlePointerPanZoomUpdate(PointerPanZoomUpdateEvent event) { - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - final dy = event.pan.dy - _panZoomLast.dy; - _panZoomLast = event.pan; - // Pan has the opposite sign of scrollDelta (finger up = negative pan.dy = - // reveal content below = scroll down); negating it reuses the convention. - _forwardScroll(-dy, event.localPosition, discrete: false); - } - - /// Converts a vertical delta (px, `scrollDelta` convention) into line steps - /// and forwards them to the app as wheel reports (button 4 = up, 5 = down). - /// [discrete] means a mouse (at least one line per notch, no accumulation); - /// otherwise the delta is continuous, as from a trackpad. - void _forwardScroll( - double deltaY, - Offset localPosition, { - required bool discrete, - }) { - final cellHeight = widget.metrics.cellHeight; - if (cellHeight <= 0) return; - final lines = deltaY / cellHeight; - if (lines == 0) return; - - final int steps; - if (discrete) { - final mag = lines.abs().round(); - steps = (mag < 1 ? 1 : mag) * (lines.isNegative ? -1 : 1); - } else { - _wheelAccum += lines; - steps = _wheelAccum.truncate(); - if (steps == 0) return; - _wheelAccum -= steps; - } - - // dy < 0 = scroll up = button 4; dy > 0 = scroll down = button 5. - final button = steps < 0 ? MouseButton.four : MouseButton.five; - for (var i = 0; i < steps.abs(); i++) { - _binding.handleMouseEvent(( - action: MouseAction.press, - button: button, - pixelX: localPosition.dx, - pixelY: localPosition.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/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/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..1d6ea580 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/controller/terminal_controller_test.dart @@ -3,13 +3,11 @@ 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/input_message.dart'; +import 'package:flterm/src/interaction/selection_session.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; @@ -38,15 +36,667 @@ void main() { terminal.write(Uint8List.fromList(utf8.encode(text))); } + void enableMouseTracking( + TerminalControllerImpl target, { + String sequence = '\x1b[?1002h\x1b[?1006h', + double devicePixelRatio = 1.0, + }) { + writeControllerUtf8(target, sequence); + target.handleResize( + SurfaceMeasurement( + 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(controller.terminal, isA()); }); - test('starts without selection, selected text, or focus', () { + test('starts without selection or selected text', () { expect(controller.hasSelection, isFalse); expect(controller.selectedText(), ''); - expect(controller.hasFocus, isFalse); + }); + }); + + group('geometry', () { + test('does not notify a resize observer 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)); + }; + + controller.handleResize( + const SurfaceMeasurement( + 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 observer is assigned after measurement', + () { + controller.handleResize( + const SurfaceMeasurement( + 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('resize callback observes committed physical geometry', () { + final binding = controller; + final output = []; + controller.onOutput = output.add; + controller.onResize = (_, _) { + controller.write(Uint8List.fromList(utf8.encode('\x1b[14t'))); + }; + + binding.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect(utf8.decode(output.single), '\x1b[4;384;640t'); + }); + + 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(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', () { + controller.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 2, + ), + ); + + expect( + controller.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 1280, + heightPx: 768, + ), + ); + }); + + test('updates physical geometry when the grid is unchanged', () { + final binding = controller; + binding.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + binding.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 10, + cellHeight: 20, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 800, + heightPx: 480, + ), + ); + }); + + test('ignores resize events with invalid physical geometry', () { + final binding = controller; + binding.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + binding.handleResize( + const SurfaceMeasurement( + cols: 100, + rows: 30, + cellWidth: 0, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + ), + ); + }); + + test('ignores resize events beyond the native grid limit', () { + final binding = controller; + + binding.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + binding.handleResize( + const SurfaceMeasurement( + cols: 65536, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + devicePixelRatio: 1, + ), + ); + + expect( + binding.terminal.geometry, + const TerminalGeometry( + cols: 80, + rows: 24, + widthPx: 640, + heightPx: 384, + ), + ); + }); + + test('emits the measured in-band resize report', () { + final binding = controller; + final output = []; + controller.onOutput = output.add; + binding.terminal.modeSet( + const TerminalMode.inBandResize(), + value: true, + ); + + binding.handleResize( + const SurfaceMeasurement( + 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('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 = 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 SurfaceMeasurement( + 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 = 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 SurfaceMeasurement( + 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; + + controller.handleMouseEvent( + const MouseInput( + action: .press, + anyButtonPressed: true, + button: .right, + mods: Mods.none(), + pixelX: 4, + pixelY: 8, + ), + ); + controller.handleMouseEvent( + const MouseInput( + 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; + + controller.handleMouseEvent( + const MouseInput( + action: .motion, + anyButtonPressed: false, + button: .left, + mods: Mods.none(), + pixelX: 1000, + pixelY: 1000, + ), + ); + + controller.handleMouseEvent( + const MouseInput( + 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; + + controller.handleMouseEvent( + const MouseInput( + 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'); + controller.handleResize( + const SurfaceMeasurement( + cols: 80, + rows: 24, + cellWidth: 8, + cellHeight: 16, + paddingLeft: 8, + paddingRight: 4, + paddingTop: 6, + paddingBottom: 2, + devicePixelRatio: 1, + ), + ); + final output = []; + controller.onOutput = output.add; + + controller.handleMouseEvent( + const MouseInput( + 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); + controller.terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h')), + ); + final output = []; + controller.onOutput = output.add; + + controller.handleTerminalScroll( + const ScrollInput( + 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; + + controller.handleTerminalScroll( + const ScrollInput( + 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', + () { + controller.terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h\x1b[?1007l')), + ); + final output = []; + controller.onOutput = output.add; + + controller.handleTerminalScroll( + const ScrollInput( + 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', () { + controller.terminal.write( + Uint8List.fromList(utf8.encode('\x1b[?1049h')), + ); + enableMouseTracking(controller); + final output = []; + controller.onOutput = output.add; + + controller.handleTerminalScroll( + const ScrollInput( + horizontal: 0, + mods: Mods.none(), + pixelX: 24, + pixelY: 16, + reportMouse: false, + vertical: -1, + ), + ); + + expect(output, isEmpty); }); }); @@ -205,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'), + ); }); }); @@ -216,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)); }); }); @@ -339,162 +992,37 @@ void main() { expect(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, + custom.handleResize( + const SurfaceMeasurement( + 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); - }); - }); - - 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); - }); + expect(custom.terminal.scrollbar.offset, offset); }); - 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(custom.terminal.scrollbar.offset, offset); }); }); @@ -821,7 +1349,7 @@ void main() { writeTerminalUtf8(custom.terminal, '\x1b[0 q'); renderState.update(custom.terminal); - expect(renderState.cursor.shape, CursorShape.underline); + expect(renderState.cursor.visualStyle, CursorShape.underline); expect(renderState.cursor.blinking, isTrue); }); @@ -854,7 +1382,7 @@ void main() { writeTerminalUtf8(controller.terminal, '\x1b[0 q'); renderState.update(controller.terminal); - expect(renderState.cursor.shape, CursorShape.bar); + expect(renderState.cursor.visualStyle, CursorShape.bar); expect(renderState.cursor.blinking, isFalse); }); }); @@ -916,6 +1444,27 @@ void main() { expect(notifyCount, greaterThan(0)); }); + test('notifies listeners once when a PWD callback is set', () { + var notifyCount = 0; + controller.onPwdChanged = () {}; + controller.addListener(() => notifyCount++); + + writeTerminalUtf8(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(controller.terminal, '\x1b]7;file:///tmp\x07'); + + expect(notifyCount, 1); + }); + test('fires onPwdChanged callback', () { var fired = false; controller.onPwdChanged = () => fired = true; @@ -941,6 +1490,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 +1599,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/fixtures/fonts/SymbolsNerdFont-Regular-subset.ttf b/packages/flterm/test/fixtures/fonts/SymbolsNerdFont-Regular-subset.ttf new file mode 100644 index 00000000..01fddf99 Binary files /dev/null and b/packages/flterm/test/fixtures/fonts/SymbolsNerdFont-Regular-subset.ttf differ 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/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_theme_test.dart b/packages/flterm/test/foundation/terminal_theme_test.dart index 372b699c..3f0f1082 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', () { @@ -290,6 +290,12 @@ void main() { expect(theme.selection, const SelectionTheme()); }); + test('prioritizes the proportional Nerd Symbols fallback', () { + final theme = TerminalTheme.dark(); + + expect(theme.fontFamilyFallback.first, 'Symbols Nerd Font'); + }); + test('defaults: fully opaque background, per-cell opacity off', () { final theme = TerminalTheme.dark(); expect(theme.backgroundOpacity, 1.0); diff --git a/packages/flterm/test/input/interaction_region_test.dart b/packages/flterm/test/input/interaction_region_test.dart new file mode 100644 index 00000000..57d9fc69 --- /dev/null +++ b/packages/flterm/test/input/interaction_region_test.dart @@ -0,0 +1,3758 @@ +@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/interaction_region.dart'; +import 'package:flterm/src/links/link_interaction.dart'; +import 'package:flterm/src/links/link_settings.dart'; +import 'package:flterm/src/view/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('InteractionRegion', () { + 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 = {}; + + ViewAttachment bindingFor(TerminalController controller) { + return adapters.putIfAbsent(controller, () { + final adapter = ViewAttachment(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( + SurfaceMeasurement( + 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, + ViewAttachment? attachment, + CellMetrics metrics = defaultMetrics, + TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), + LinkInteraction? links, + ValueChanged? onLinkActivate, + ScrollController? scrollController, + 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: InteractionRegion( + attachment: resolvedAttachment, + metrics: metrics, + interaction: resolvedAttachment.interaction.value, + links: resolvedLinks, + onLinkActivate: onLinkActivate, + settings: gestureSettings, + scrollController: scrollController, + scrollPhysics: scrollPhysics, + child: const SizedBox(width: 640, height: 384), + ), + ), + ); + } + + LinkInteraction linkInteractionFor(TerminalController controller) { + final links = LinkInteraction(); + addTearDown(links.dispose); + 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(() { + adapters.clear(); + controller = TerminalController(); + }); + + tearDown(() { + controller.dispose(); + adapters.clear(); + }); + + 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(); + } + } + + group('selection ownership', () { + 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( + SurfaceMeasurement( + 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 region 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/text_input_session_test.dart similarity index 78% rename from packages/flterm/test/widgets/terminal_input_client_test.dart rename to packages/flterm/test/input/text_input_session_test.dart index d6d5d370..854ca4e9 100644 --- a/packages/flterm/test/widgets/terminal_input_client_test.dart +++ b/packages/flterm/test/input/text_input_session_test.dart @@ -1,12 +1,12 @@ -import 'package:flterm/src/widgets.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 = []; @@ -64,6 +64,46 @@ void main() { tearDown(() => handler.detach()); group('updateEditingValueWithDeltas', () { + test('recovers when a delta cannot apply to the sentinel value', () { + final calls = recordTextInputCalls(); + handler.ensureAttached(); + calls.clear(); + + // A platform that ignored our sentinel reset (e.g. iOS after an accent) + // can send a delta referencing offsets past our value. Applying it + // would throw; the session must recover instead of freezing. + expect( + () => handler.updateEditingValueWithDeltas([ + const TextEditingDeltaDeletion( + oldText: 'abcde', + deletedRange: TextRange(start: 3, end: 5), + selection: TextSelection.collapsed(offset: 3), + composing: TextRange.empty, + ), + ]), + returnsNormally, + ); + + // Channel stays alive and is resynced back to the sentinel. + expect(handler.isAttached, isTrue); + expect( + calls.where((call) => call.method == 'TextInput.setEditingState'), + isNotEmpty, + ); + + // Subsequent normal input still commits. + handler.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: ' ', + textInserted: 'x', + insertionOffset: 1, + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + ]); + expect(commits, ['x']); + }); + test('commits inserted text', () { handler.updateEditingValueWithDeltas([ const TextEditingDeltaInsertion( @@ -152,6 +192,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 +365,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 +664,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 +772,7 @@ void main() { test('does not detach text input', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); handler.updateEditingValue( const TextEditingValue( text: ' \u4f60', @@ -793,6 +1017,22 @@ void main() { }); group('ensureAttached', () { + test('throws when no Flutter view is set', () { + final client = TextInputSession(); + 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 +1053,10 @@ void main() { test('reopens a connection orphaned by another client', () { final calls = recordTextInputCalls(); - handler.attach(); - final other = TerminalInputClient()..viewId = 0; + handler.ensureAttached(); + final other = TextInputSession()..viewId = 0; addTearDown(other.detach); - other.attach(); + other.ensureAttached(); calls.clear(); handler.ensureAttached(); @@ -827,7 +1067,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', @@ -835,21 +1075,54 @@ void main() { composing: TextRange(start: 1, end: 3), ), ); - final other = TerminalInputClient()..viewId = 0; + final other = TextInputSession()..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 +1132,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 +1146,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 +1158,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 +1171,7 @@ void main() { group('updateGeometry', () { test('reports active connection geometry', () { final calls = recordTextInputCalls(); - handler.attach(); + handler.ensureAttached(); calls.clear(); handler.updateGeometry( @@ -943,69 +1216,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/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/atlas/lanes/text_lane_test.dart b/packages/flterm/test/rendering/atlas/lanes/text_lane_test.dart index 21494003..0f4d9848 100644 --- a/packages/flterm/test/rendering/atlas/lanes/text_lane_test.dart +++ b/packages/flterm/test/rendering/atlas/lanes/text_lane_test.dart @@ -36,7 +36,7 @@ void main() { lane.dispose(); }); - Future<({int left, int right, int width})> paintedBounds( + Future<({int height, int left, int right, int width})> paintedBounds( Image image, AtlasEntry entry, ) async { @@ -49,17 +49,24 @@ void main() { final bottom = entry.srcBottom.ceil(); var paintedLeft = right; var paintedRight = left - 1; + var paintedTop = bottom; + var paintedBottom = top - 1; for (var y = top; y < bottom; y++) { for (var x = left; x < right; x++) { final alpha = data[(y * imageWidth + x) * 4 + 3]; if (alpha > 0) { paintedLeft = x < paintedLeft ? x : paintedLeft; paintedRight = x > paintedRight ? x : paintedRight; + paintedTop = y < paintedTop ? y : paintedTop; + paintedBottom = y > paintedBottom ? y : paintedBottom; } } } return ( + height: paintedBottom >= paintedTop + ? paintedBottom - paintedTop + 1 + : 0, left: paintedLeft, right: paintedRight, width: paintedRight >= paintedLeft ? paintedRight - paintedLeft + 1 : 0, @@ -136,6 +143,138 @@ void main() { expect(insetDelta, lessThanOrEqualTo(2)); }); + test('rasterizeText centers borrowed symbols in the first cell', () async { + final lane = TextLane() + ..configure( + config( + metrics: const CellMetrics( + cellWidth: 32, + cellHeight: 32, + baseline: 24, + ), + ), + ); + addTearDown(lane.dispose); + final centered = lane.rasterizeText( + 'A', + bold: false, + italic: false, + span: 2, + ); + final centerInFirstCell = lane.rasterizeText( + 'A', + bold: false, + italic: false, + span: 2, + centerInFirstCell: true, + ); + + lane.ensureImage(); + final image = lane.image!; + final centeredBounds = await paintedBounds(image, centered); + final firstCellBounds = await paintedBounds(image, centerInFirstCell); + + final centeredInset = centeredBounds.left - centered.srcLeft.floor(); + final firstCellInset = + firstCellBounds.left - centerInFirstCell.srcLeft.floor(); + expect(firstCellInset, lessThan(centeredInset)); + expect(firstCellBounds.width, centeredBounds.width); + }); + + test('rasterizeText uniformly fits an oversized glyph', () async { + final regularLane = TextLane(initialSize: 128, maxSize: 128) + ..configure( + config( + metrics: const CellMetrics( + cellWidth: 32, + cellHeight: 32, + baseline: 24, + ), + ), + ); + final constrainedLane = TextLane(initialSize: 128, maxSize: 128) + ..configure( + config( + metrics: const CellMetrics( + cellWidth: 4, + cellHeight: 32, + baseline: 24, + ), + ), + ); + addTearDown(regularLane.dispose); + addTearDown(constrainedLane.dispose); + final regular = regularLane.rasterizeText( + '■', + bold: false, + italic: false, + ); + final constrained = constrainedLane.rasterizeText( + '■', + bold: false, + italic: false, + ); + regularLane.ensureImage(); + constrainedLane.ensureImage(); + + final regularBounds = await paintedBounds(regularLane.image!, regular); + final constrainedBounds = await paintedBounds( + constrainedLane.image!, + constrained, + ); + + expect(constrainedBounds.width, lessThanOrEqualTo(4)); + expect(constrainedBounds.height, lessThan(regularBounds.height)); + }); + + test('rasterizeText uniformly fits a glyph taller than its cell', () async { + final regularLane = TextLane(initialSize: 128, maxSize: 128) + ..configure( + config( + metrics: const CellMetrics( + cellWidth: 32, + cellHeight: 32, + baseline: 24, + ), + ), + ); + final constrainedLane = TextLane(initialSize: 128, maxSize: 128) + ..configure( + config( + metrics: const CellMetrics( + cellWidth: 32, + cellHeight: 8, + baseline: 6, + ), + ), + ); + addTearDown(regularLane.dispose); + addTearDown(constrainedLane.dispose); + final regular = regularLane.rasterizeText( + '■', + bold: false, + italic: false, + ); + final constrained = constrainedLane.rasterizeText( + '■', + bold: false, + italic: false, + span: 2, + centerInFirstCell: true, + ); + regularLane.ensureImage(); + constrainedLane.ensureImage(); + + final regularBounds = await paintedBounds(regularLane.image!, regular); + final constrainedBounds = await paintedBounds( + constrainedLane.image!, + constrained, + ); + + expect(constrainedBounds.height, lessThanOrEqualTo(8)); + expect(constrainedBounds.width, lessThan(regularBounds.width)); + }); + test('clear drops pending text and releases the image', () { lane.rasterizeText('A', bold: false, italic: false); lane.ensureImage(); 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 af2034e2..f421f369 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'; @@ -15,16 +15,16 @@ import 'package:libghostty/libghostty.dart'; import 'helpers/font_loader.dart'; void main() { - group('Cursor goldens', () { + group('RenderStateCursor goldens', () { const cjkFallback = ['Noto Sans JP', 'JetBrains Mono']; const cols = 15; 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( @@ -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 = FrameSource(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(), + atlasPool: atlasPool(), + 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..1edd67d1 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,10 @@ void main() { bool focused = true, }) async { selection?.applyTo(terminal); + final frameSource = FrameSource(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), + atlasPool: atlasPool(), + focused: focused, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -295,7 +300,7 @@ void main() { }); }); - group('Cursor on emoji', () { + group('RenderStateCursor on emoji', () { testWidgets('block cursor on standard wide emoji', (tester) async { final terminal = Terminal(cols: defaultCols, rows: defaultRows); addTearDown(terminal.dispose); @@ -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/terminal_frame_builder_test.dart b/packages/flterm/test/rendering/frame_builder_test.dart similarity index 80% rename from packages/flterm/test/rendering/terminal_frame_builder_test.dart rename to packages/flterm/test/rendering/frame_builder_test.dart index 30374264..14c74b1c 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(() { @@ -146,13 +146,94 @@ void main() { expect(atlas.emojiImage, isNotNull); }); + test('sync lets a private-use symbol occupy a following blank cell', () { + writeUtf8(terminal, '\u{E5FF} A'); + + builder.sync(terminal, terminalDirty: true); + + expect( + (sprites.regular.count, sprites.wide.count, sprites.emoji.count), + (1, 1, 0), + ); + }); + + test('sync constrains a private-use symbol before text to one cell', () { + writeUtf8(terminal, '\u{E5FF}A'); + + builder.sync(terminal, terminalDirty: true); + + expect(sprites.regular.count, 2); + expect(sprites.wide.count, 0); + }); + + test('sync constrains consecutive private-use symbols to one cell', () { + writeUtf8(terminal, '\u{E5FF}\u{E5FF} '); + + builder.sync(terminal, terminalDirty: true); + + expect(sprites.regular.count, 2); + expect(sprites.wide.count, 0); + }); + + test('sync lets a symbol borrow after an invisible symbol', () { + writeUtf8(terminal, '\x1b[8m\u{E5FF}\x1b[28m\u{E5FF} '); + + builder.sync(terminal, terminalDirty: true); + + expect(sprites.wide.count, 1); + }); + + test('sync lets a symbol borrow after a preedit replacement', () { + writeUtf8(terminal, '\u{E5FF}\u{E5FF} \x1b[1;1H'); + + builder.sync(terminal, terminalDirty: true, preeditText: 'a'); + + expect(sprites.wide.count, 1); + }); + + test('sync constrains a final-column private-use symbol to one cell', () { + final frame = createFrame(cols: 1, rows: 1); + writeUtf8(frame.terminal, '\u{E5FF}'); + + frame.builder.sync(frame.terminal, terminalDirty: true); + + expect(frame.sprites.regular.count, 1); + expect(frame.sprites.wide.count, 0); + }); + + test( + 'sync does not expand a private-use symbol over nonbreaking space', + () { + writeUtf8(terminal, '\u{E5FF}\u00A0'); + + builder.sync(terminal, terminalDirty: true); + + expect(sprites.wide.count, 0); + }, + ); + + test('sync rebuilds external and terminal dirty rows together', () { + writeUtf8(terminal, 'A\r\nB'); + builder.sync(terminal, terminalDirty: true); + builder.markRowsDirty(1, 2); + writeUtf8(terminal, '\x1b[1;1HC'); + + builder.sync(terminal, terminalDirty: true); + + expect(sprites.regular.count, 2); + }); + test('sync emits operator ligatures without adding text atlas entries', () { final initialCacheSize = atlas.cacheSize; - writeUtf8(terminal, '=> !='); + writeUtf8(terminal, '=> != ==='); builder.sync(terminal, terminalDirty: true); - expect(sprites.shaped.count, 2); + expect(sprites.shaped.count, 3); + expect( + sprites.shaped.rows.where((row) => row.isNotEmpty).single, + hasLength(3), + ); expect(atlas.cacheSize, initialCacheSize); }); @@ -160,14 +241,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(); @@ -186,7 +264,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 +324,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 +336,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'); @@ -287,7 +365,7 @@ void main() { builder.sync(terminal, terminalDirty: true); expect(state.cursor.visible, isTrue); - expect(state.cursor.position.col, 0); + expect(state.cursor.viewportX, 0); expect(state.cursorAtlasEntry, isNotNull); }); @@ -319,7 +397,7 @@ void main() { builder.sync(terminal, terminalDirty: true); expect(state.cursor.visible, isTrue); - expect(state.cursor.shape, CursorShape.underline); + expect(state.cursor.visualStyle, CursorShape.underline); expect(state.cursorAtlasEntry, isNull); }); 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/goldens/kitty_stale_replacement.png b/packages/flterm/test/rendering/goldens/kitty_stale_replacement.png new file mode 100644 index 00000000..6fd66c4d Binary files /dev/null and b/packages/flterm/test/rendering/goldens/kitty_stale_replacement.png differ diff --git a/packages/flterm/test/rendering/goldens/text_nerd_font_symbol_spacing.png b/packages/flterm/test/rendering/goldens/text_nerd_font_symbol_spacing.png new file mode 100644 index 00000000..07274167 Binary files /dev/null and b/packages/flterm/test/rendering/goldens/text_nerd_font_symbol_spacing.png differ diff --git a/packages/flterm/test/rendering/goldens/text_styles.png b/packages/flterm/test/rendering/goldens/text_styles.png index 237b550c..ffdb9613 100644 Binary files a/packages/flterm/test/rendering/goldens/text_styles.png and b/packages/flterm/test/rendering/goldens/text_styles.png differ diff --git a/packages/flterm/test/rendering/helpers/font_loader.dart b/packages/flterm/test/rendering/helpers/font_loader.dart index 84d0d526..02ea3f14 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 @@ -23,6 +40,12 @@ const bundledFontFamilyFallback = [ 'Noto Sans JP', ]; +/// Font family fallback list for deterministic Nerd Font golden tests. +const bundledNerdFontFamilyFallback = [ + 'Symbols Nerd Font', + ...bundledFontFamilyFallback, +]; + Future loadBundledFonts() async { // DirectWrite leaves the COLRv1 and SVG fixture blank, so Windows uses the // equivalent CBDT subset for deterministic pixel bounds. @@ -37,6 +60,7 @@ Future loadBundledFonts() async { await _load(colorEmojiFilename, 'Noto Color Emoji'); await _load('NotoEmoji-Regular.ttf', 'Noto Emoji'); await _load('NotoSansJP-Regular.ttf', 'Noto Sans JP'); + await _load('SymbolsNerdFont-Regular-subset.ttf', 'Symbols Nerd Font'); } Future _load(String filename, String family) async { @@ -45,3 +69,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/kitty_graphics_painter_golden_test.dart b/packages/flterm/test/rendering/kitty_graphics_painter_golden_test.dart index 3b49ee52..eb3ef977 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()), @@ -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), ) @@ -183,6 +183,62 @@ void main() { ); }); + testWidgets('does not draw stale pixels for replacement geometry', ( + tester, + ) async { + late ui.Image captured; + await tester.runAsync(() async { + final cache = KittyImageCache(onImageReady: () {}); + addTearDown(cache.dispose); + cache.putReady( + 1, + await imageFromRgba( + Uint8List.fromList([0xff, 0x00, 0x00, 0xff]), + 1, + 1, + ), + generation: 1, + ); + cache.putReady( + 2, + await imageFromRgba( + Uint8List.fromList([0x00, 0xff, 0x00, 0xff]), + 1, + 1, + ), + generation: 2, + ); + + const snapshots = [ + KittyPlacementSnapshot( + imageId: 1, + imageGeneration: 2, + dst: Rect.fromLTWH(0, 0, 80, 80), + src: Rect.fromLTWH(0, 0, 1, 1), + z: 0, + ), + KittyPlacementSnapshot( + imageId: 2, + imageGeneration: 2, + dst: Rect.fromLTWH(24, 24, 32, 32), + src: Rect.fromLTWH(0, 0, 1, 1), + z: 1, + ), + ]; + final painter = KittyGraphicsPainter( + state: stateFor(cols: 80, rows: 80), + cache: cache, + snapshots: snapshots, + ); + + captured = await paint(width: 80, height: 80, draw: painter.paint); + }); + await expectLater( + captured, + matchesGoldenFile('goldens/kitty_stale_replacement.png'), + ); + }); + testWidgets('routes a PNG through the full pipeline', (tester) async { late ui.Image captured; await tester.runAsync(() async { @@ -201,12 +257,14 @@ void main() { final image = KittyGraphics.of(terminal)!.image(1)!; expect(image.width, 64); expect(image.height, 64); + final pixels = Uint8List(image.width * image.height * 4); + image.copyPixelDataInto(pixels); final cache = KittyImageCache(onImageReady: () {}); addTearDown(cache.dispose); cache.putReady( image.id, - await imageFromRgba(image.pixelData, image.width, image.height), + await imageFromRgba(pixels, image.width, image.height), ); final snapshots = [ diff --git a/packages/flterm/test/rendering/kitty_image_cache_test.dart b/packages/flterm/test/rendering/kitty_image_cache_test.dart index 0b8c501d..63543455 100644 --- a/packages/flterm/test/rendering/kitty_image_cache_test.dart +++ b/packages/flterm/test/rendering/kitty_image_cache_test.dart @@ -43,6 +43,18 @@ void main() { expect(cache.dispose, returnsNormally); }); + + test('rejects lookup after disposal', () { + final terminal = Terminal(cols: 1, rows: 1); + addTearDown(terminal.dispose); + terminal.write( + Uint8List.fromList('\x1b_Gf=24,s=1,v=1,a=t,i=1;/wAA\x1b\\'.codeUnits), + ); + final image = KittyGraphics.of(terminal)!.image(1)!; + final cache = KittyImageCache(onImageReady: () {})..dispose(); + + expect(() => cache.lookup(image), throwsStateError); + }); }); group('lookup', () { @@ -53,6 +65,19 @@ void main() { ); } + void applyImagePressure( + KittyImageCache cache, + Terminal terminal, { + required int firstId, + required int lastId, + }) { + for (var imageId = firstId; imageId <= lastId; imageId++) { + terminal.write(transmitPixel(id: imageId, rgb: [imageId, 0, 0])); + cache.lookup(KittyGraphics.of(terminal)!.image(imageId)!); + cache.evict({1, imageId}); + } + } + late Terminal terminal; setUp(() { @@ -63,19 +88,365 @@ void main() { terminal.dispose(); }); - test('invalidates ready entry when image generation changes', () async { - final cache = KittyImageCache(onImageReady: () {}); + test( + 'retains prior ready image during valid replacement decode', + () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 7, rgb: [0xff, 0x00, 0x00])); + final firstImage = KittyGraphics.of(terminal)!.image(7)!; + cache.lookup(firstImage); + final firstDecoded = await testImage(); + callbacks.single(firstDecoded); + final previous = cache.lookupById(7); + + terminal.write(transmitPixel(id: 7, rgb: [0x00, 0xff, 0x00])); + final replacement = KittyGraphics.of(terminal)!.image(7)!; + cache.lookup(replacement); + + final current = cache.lookupById(7); + + expect(current, same(previous)); + }, + ); + + test( + 'publishes the newest generation after a stale decode completes', + () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 8, rgb: [0xff, 0x00, 0x00])); + final firstImage = KittyGraphics.of(terminal)!.image(8)!; + cache.lookup(firstImage); + terminal.write(transmitPixel(id: 8, rgb: [0x00, 0xff, 0x00])); + final replacement = KittyGraphics.of(terminal)!.image(8)!; + cache.lookup(replacement); + final firstDecoded = await testImage(); + final replacementDecoded = await testImage(); + + callbacks.single(firstDecoded); + callbacks.last(replacementDecoded); + + final entry = cache.lookupById(8)! as KittyImageReady; + + expect(entry.image, same(replacementDecoded)); + }, + ); + + test('publishes a replacement after its decode completes', () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 9, rgb: [0xff, 0x00, 0x00])); + final firstImage = KittyGraphics.of(terminal)!.image(9)!; + cache.lookup(firstImage); + callbacks.single(await testImage()); + terminal.write(transmitPixel(id: 9, rgb: [0x00, 0xff, 0x00])); + final replacement = KittyGraphics.of(terminal)!.image(9)!; + cache.lookup(replacement); + final replacementDecoded = await testImage(); + + callbacks.last(replacementDecoded); + + final entry = cache.lookupById(9)! as KittyImageReady; + + expect(entry.image, same(replacementDecoded)); + }); + + test('reuses a ready image for an unchanged generation', () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); addTearDown(cache.dispose); - final decoded = await testImage(); - cache.putReady(7, decoded); - terminal.write(transmitPixel(id: 7, rgb: [0xff, 0x00, 0x00])); - final image = KittyGraphics.of(terminal)!.image(7)!; + terminal.write(transmitPixel(id: 13, rgb: [0xff, 0x00, 0x00])); + final image = KittyGraphics.of(terminal)!.image(13)!; + cache.lookup(image); + callbacks.single(await testImage()); + final ready = cache.lookupById(13); - final entry = cache.lookup(image); + final reused = cache.lookup(image); - expect(entry, isA()); + expect(reused, same(ready)); }); + test('expands RGB channels in Flutter pixel order', () { + late Uint8List decodedPixels; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, _) => decodedPixels = pixels, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 24, rgb: [0x12, 0x34, 0x56])); + + cache.lookup(KittyGraphics.of(terminal)!.image(24)!); + + expect(decodedPixels, [0x12, 0x34, 0x56, 0xff]); + }); + + test('expands every RGB pixel without crossing channel boundaries', () { + late Uint8List decodedPixels; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, _) => decodedPixels = pixels, + ); + addTearDown(cache.dispose); + final rgb = [ + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a, + 0xbc, + 0xde, + 0xf0, + 0x11, + 0x22, + 0x33, + 0x44, + 0x55, + 0x66, + 0x77, + ]; + terminal.write( + Uint8List.fromList( + '\x1b_Gf=24,s=5,v=1,a=t,i=25;${base64Encode(rgb)}\x1b\\'.codeUnits, + ), + ); + + cache.lookup(KittyGraphics.of(terminal)!.image(25)!); + + expect(decodedPixels, [ + 0x12, + 0x34, + 0x56, + 0xff, + 0x78, + 0x9a, + 0xbc, + 0xff, + 0xde, + 0xf0, + 0x11, + 0xff, + 0x22, + 0x33, + 0x44, + 0xff, + 0x55, + 0x66, + 0x77, + 0xff, + ]); + }); + + test('reuses RGB expansion storage after decode completion', () async { + final callbacks = []; + final decodedPixels = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, callback) { + decodedPixels.add(pixels); + callbacks.add(callback); + }, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 27, rgb: [0x10, 0x20, 0x30])); + cache.lookup(KittyGraphics.of(terminal)!.image(27)!); + terminal.write(transmitPixel(id: 28, rgb: [0x40, 0x50, 0x60])); + cache.lookup(KittyGraphics.of(terminal)!.image(28)!); + + callbacks.single(await testImage()); + + expect(decodedPixels, hasLength(2)); + expect(decodedPixels.last, same(decodedPixels.first)); + expect(decodedPixels.last, [0x40, 0x50, 0x60, 0xff]); + }); + + test('bounds concurrent decodes while newer images arrive', () { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 14, rgb: [0xff, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(14)!); + terminal.write(transmitPixel(id: 15, rgb: [0x00, 0xff, 0x00])); + + cache.lookup(KittyGraphics.of(terminal)!.image(15)!); + + expect(callbacks, hasLength(1)); + }); + + test( + 'continues queued decoding when readiness notification throws', + () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () => throw StateError('notification failed'), + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 25, rgb: [0x10, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(25)!); + terminal.write(transmitPixel(id: 26, rgb: [0x20, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(26)!); + + final decoded = await testImage(); + + expect(() => callbacks.single(decoded), throwsStateError); + + expect(callbacks, hasLength(2)); + }, + ); + + test('skips a queued image evicted before decoding', () async { + final callbacks = []; + final decodedRedChannels = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, callback) { + decodedRedChannels.add(pixels.first); + callbacks.add(callback); + }, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 16, rgb: [0x10, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(16)!); + terminal.write(transmitPixel(id: 17, rgb: [0x20, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(17)!); + terminal.write(transmitPixel(id: 18, rgb: [0x30, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(18)!); + cache.evict({16, 18}); + + callbacks.single(await testImage()); + + expect(decodedRedChannels, [0x10, 0x30]); + }); + + test( + 'coalesces sustained changing-ID pressure to the latest frame', + () async { + final callbacks = []; + final decodedRedChannels = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, callback) { + decodedRedChannels.add(pixels.first); + callbacks.add(callback); + }, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 1, rgb: [1, 0, 0])); + cache.lookup(KittyGraphics.of(terminal)!.image(1)!); + applyImagePressure(cache, terminal, firstId: 2, lastId: 120); + + callbacks.single(await testImage()); + + expect(decodedRedChannels, [1, 120]); + }, + ); + + test('does not let a refreshed image starve queued work', () async { + final callbacks = []; + final decodedRedChannels = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (pixels, _, _, _, callback) { + decodedRedChannels.add(pixels.first); + callbacks.add(callback); + }, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 19, rgb: [0x10, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(19)!); + terminal.write(transmitPixel(id: 20, rgb: [0x20, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(20)!); + terminal.write(transmitPixel(id: 21, rgb: [0x30, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(21)!); + terminal.write(transmitPixel(id: 20, rgb: [0x40, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(20)!); + + callbacks.single(await testImage()); + + expect(decodedRedChannels, [0x10, 0x30]); + }); + + test('pre-decoded image removes its queued request', () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 22, rgb: [0x10, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(22)!); + terminal.write(transmitPixel(id: 23, rgb: [0x20, 0x00, 0x00])); + cache.lookup(KittyGraphics.of(terminal)!.image(23)!); + final preDecoded = await testImage(); + cache.putReady(23, preDecoded); + + callbacks.single(await testImage()); + + expect(callbacks, hasLength(1)); + }); + + test( + 'does not republish a decode that completes after disposal', + () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 10, rgb: [0xff, 0x00, 0x00])); + final image = KittyGraphics.of(terminal)!.image(10)!; + cache.lookup(image); + final decoded = await testImage(); + + cache.dispose(); + callbacks.single(decoded); + + expect(cache.lookupById(10), isNull); + }, + ); + + test( + 'ignores an evicted decode after the same generation is requested', + () async { + final callbacks = []; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 11, rgb: [0xff, 0x00, 0x00])); + final image = KittyGraphics.of(terminal)!.image(11)!; + cache.lookup(image); + cache.evict({}); + cache.lookup(image); + final evicted = await testImage(); + + callbacks.first(evicted); + + expect(cache.lookupById(11), isA()); + }, + ); + test('discards stale pending decode after generation changes', () async { final callbacks = []; final cache = KittyImageCache( @@ -97,6 +468,25 @@ void main() { expect(cache.lookupById(8), isA()); }); + + test('retries an image after the decoder throws synchronously', () { + var decodeCalls = 0; + final cache = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, _) { + decodeCalls++; + throw StateError('decode failed'); + }, + ); + addTearDown(cache.dispose); + terminal.write(transmitPixel(id: 12, rgb: [0xff, 0x00, 0x00])); + final image = KittyGraphics.of(terminal)!.image(12)!; + + expect(() => cache.lookup(image), throwsStateError); + expect(() => cache.lookup(image), throwsStateError); + + expect(decodeCalls, 2); + }); }); }); } diff --git a/packages/flterm/test/rendering/kitty_placement_cache_test.dart b/packages/flterm/test/rendering/kitty_placement_cache_test.dart index 422c5045..8adf8aca 100644 --- a/packages/flterm/test/rendering/kitty_placement_cache_test.dart +++ b/packages/flterm/test/rendering/kitty_placement_cache_test.dart @@ -1,8 +1,10 @@ @Tags(['ffi']) library; +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'dart:ui' show Image, ImageDecoderCallback, Size, decodeImageFromPixels; import 'package:flterm/src/foundation/cell_metrics.dart'; import 'package:flterm/src/foundation/terminal_theme.dart'; @@ -16,23 +18,77 @@ void main() { group('KittyPlacementCache', () { const metrics = CellMetrics(cellWidth: 8, cellHeight: 16, baseline: 12); - void writePlacedImage(Terminal terminal) { - final payload = base64Encode([0xff, 0x00, 0x00]); + void writePlacedImage( + Terminal terminal, { + int id = 11, + int width = 1, + int columns = 1, + int? placementId, + }) { + final payload = base64Encode( + List.generate(width * 3, (index) => index.isEven ? 0xff : 0), + ); + final placement = placementId == null ? '' : ',p=$placementId'; terminal.write( Uint8List.fromList( - '\x1b_Gf=24,s=1,v=1,a=T,i=11,c=1,r=1;$payload\x1b\\'.codeUnits, + '\x1b_Gf=24,s=$width,v=1,a=T,i=$id$placement,c=$columns,r=1;' + '$payload\x1b\\' + .codeUnits, ), ); } + Future testImage() { + final completer = Completer(); + decodeImageFromPixels( + Uint8List.fromList([0xff, 0xff, 0xff, 0xff]), + 1, + 1, + .rgba8888, + completer.complete, + ); + return completer.future; + } + + ({ + List decodeCallbacks, + KittyImageCache images, + KittyPlacementCache placements, + PaintState state, + Terminal terminal, + }) + geometryFixture() { + final decodeCallbacks = []; + final terminal = Terminal(cols: 8, rows: 2) + ..kittyImageStorageLimit = 1 << 20; + final state = PaintState(TerminalTheme.dark(), metrics) + ..cols = 8 + ..rows = 2; + final images = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => decodeCallbacks.add(callback), + ); + final placements = KittyPlacementCache(state: state, images: images); + writePlacedImage(terminal); + terminal.resize(cols: 8, rows: 2, cellWidthPx: 8, cellHeightPx: 16); + placements.sync(terminal, geometryDirty: true); + return ( + decodeCallbacks: decodeCallbacks, + images: images, + placements: placements, + state: state, + terminal: terminal, + ); + } + 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: () {}); @@ -61,6 +117,52 @@ void main() { expect(rebuilt, isTrue); }); + test('refreshes destination geometry after a physical resize', () async { + final fixture = geometryFixture(); + addTearDown(fixture.images.dispose); + addTearDown(fixture.terminal.dispose); + fixture.decodeCallbacks.single(await testImage()); + fixture.placements.sync(fixture.terminal, geometryDirty: false); + fixture.state.metrics = const CellMetrics( + cellWidth: 16, + cellHeight: 32, + baseline: 24, + ); + fixture.terminal.resize( + cols: 8, + rows: 2, + cellWidthPx: 16, + cellHeightPx: 32, + ); + fixture.placements.sync(fixture.terminal, geometryDirty: true); + + expect( + fixture.placements.snapshots.single.dst.size, + const Size(16, 32), + ); + }); + + test('does not request another decode after a physical resize', () { + final fixture = geometryFixture(); + addTearDown(fixture.images.dispose); + addTearDown(fixture.terminal.dispose); + fixture.state.metrics = const CellMetrics( + cellWidth: 16, + cellHeight: 32, + baseline: 24, + ); + fixture.terminal.resize( + cols: 8, + rows: 2, + cellWidthPx: 16, + cellHeightPx: 32, + ); + + fixture.placements.sync(fixture.terminal, geometryDirty: true); + + expect(fixture.decodeCallbacks, hasLength(1)); + }); + test('removes snapshots hidden by terminal scrolling', () { terminal.write(Uint8List.fromList('\x1b[2;1H\n'.codeUnits)); @@ -68,6 +170,266 @@ void main() { expect(placements.snapshots, isEmpty); }); + + test('keeps an incomplete chunked transmission non-drawable', () async { + final callbacks = []; + final chunkedTerminal = Terminal(cols: 8, rows: 2) + ..kittyImageStorageLimit = 1 << 20; + final chunkedState = PaintState(TerminalTheme.dark(), metrics) + ..cols = 8 + ..rows = 2; + final chunkedImages = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + final chunkedPlacements = KittyPlacementCache( + state: chunkedState, + images: chunkedImages, + ); + addTearDown(chunkedImages.dispose); + addTearDown(chunkedTerminal.dispose); + chunkedTerminal.resize( + cols: 8, + rows: 2, + cellWidthPx: 8, + cellHeightPx: 16, + ); + chunkedTerminal.write( + Uint8List.fromList( + '\x1b_Ga=t,f=24,s=1,v=2,i=31,m=1;/wAA\x1b\\' + '\x1b_Ga=p,i=31,c=1,r=1\x1b\\' + .codeUnits, + ), + ); + expect( + () => chunkedPlacements.sync(chunkedTerminal, geometryDirty: true), + returnsNormally, + ); + expect(chunkedPlacements.snapshots, isEmpty); + + chunkedTerminal.write( + Uint8List.fromList('\x1b_Gm=0;AP8A\x1b\\'.codeUnits), + ); + chunkedTerminal.write( + Uint8List.fromList('\x1b_Ga=p,i=31,c=1,r=1\x1b\\'.codeUnits), + ); + chunkedPlacements.sync(chunkedTerminal, geometryDirty: true); + callbacks.single(await testImage()); + chunkedPlacements.sync(chunkedTerminal, geometryDirty: true); + + expect(chunkedPlacements.snapshots, hasLength(1)); + expect(chunkedImages.lookupById(31), isA()); + }); + + test( + 'retains a complete frame while compatible replacement decodes', + () async { + final callbacks = []; + final controlledImages = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + final controlledPlacements = KittyPlacementCache( + state: state, + images: controlledImages, + ); + addTearDown(controlledImages.dispose); + terminal.resize(cols: 8, rows: 2, cellWidthPx: 8, cellHeightPx: 16); + controlledPlacements.sync(terminal, geometryDirty: true); + callbacks.single(await testImage()); + + terminal.write( + Uint8List.fromList('\x1b_Ga=d,d=I,i=11\x1b\\'.codeUnits), + ); + terminal.write(Uint8List.fromList('\x1b[1;1H'.codeUnits)); + writePlacedImage(terminal, id: 12); + controlledPlacements.sync(terminal, geometryDirty: true); + final whilePending = + controlledPlacements.snapshots.firstOrNull?.imageId; + final oldReadyWhilePending = + controlledImages.lookupById(11) is KittyImageReady; + callbacks.last(await testImage()); + + controlledPlacements.sync(terminal, geometryDirty: false); + + expect(whilePending, 11); + expect(oldReadyWhilePending, isTrue); + expect(controlledPlacements.snapshots.single.imageId, 12); + expect(controlledImages.lookupById(11), isNull); + }, + ); + + test( + 'retains same-id pixels while compatible replacement decodes', + () async { + final callbacks = []; + final controlledImages = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + final controlledPlacements = KittyPlacementCache( + state: state, + images: controlledImages, + ); + addTearDown(controlledImages.dispose); + terminal.resize(cols: 8, rows: 2, cellWidthPx: 8, cellHeightPx: 16); + controlledPlacements.sync(terminal, geometryDirty: true); + callbacks.single(await testImage()); + controlledPlacements.sync(terminal, geometryDirty: false); + final original = controlledPlacements.snapshots.single; + + terminal.write(Uint8List.fromList('\x1b[1;1H'.codeUnits)); + writePlacedImage(terminal); + controlledPlacements.sync(terminal, geometryDirty: true); + + expect(controlledPlacements.snapshots.single, same(original)); + + callbacks.last(await testImage()); + controlledPlacements.sync(terminal, geometryDirty: false); + + expect( + controlledPlacements.snapshots.single.imageGeneration, + isNot(original.imageGeneration), + ); + }, + ); + + test( + 'does not pair stale pixels with same-id replacement geometry', + () async { + final callbacks = []; + final controlledImages = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + final controlledPlacements = KittyPlacementCache( + state: state, + images: controlledImages, + ); + addTearDown(controlledImages.dispose); + terminal.resize(cols: 8, rows: 2, cellWidthPx: 8, cellHeightPx: 16); + controlledPlacements.sync(terminal, geometryDirty: true); + callbacks.single(await testImage()); + controlledPlacements.sync(terminal, geometryDirty: false); + final original = controlledPlacements.snapshots.single; + + writePlacedImage(terminal, width: 2, columns: 2); + controlledPlacements.sync(terminal, geometryDirty: true); + + final replacement = controlledPlacements.snapshots.single; + final staleImage = + controlledImages.lookupById(11)! as KittyImageReady; + expect(original.dst.width, metrics.cellWidth); + expect(replacement.dst.width, 2 * metrics.cellWidth); + expect(staleImage.generation, isNot(replacement.imageGeneration)); + + callbacks.last(await testImage()); + controlledPlacements.sync(terminal, geometryDirty: false); + + expect( + controlledPlacements.snapshots.single.dst.width, + 2 * metrics.cellWidth, + ); + }, + ); + + test( + 'retains compatible multi-image geometry when replacement ids reorder', + () async { + final callbacks = []; + final multiTerminal = Terminal(cols: 8, rows: 2) + ..kittyImageStorageLimit = 1 << 20; + final multiState = PaintState(TerminalTheme.dark(), metrics) + ..cols = 8 + ..rows = 2; + final controlledImages = KittyImageCache( + onImageReady: () {}, + decodeImage: (_, _, _, _, callback) => callbacks.add(callback), + ); + final controlledPlacements = KittyPlacementCache( + state: multiState, + images: controlledImages, + ); + addTearDown(controlledImages.dispose); + addTearDown(multiTerminal.dispose); + multiTerminal.resize( + cols: 8, + rows: 2, + cellWidthPx: 8, + cellHeightPx: 16, + ); + writePlacedImage(multiTerminal, placementId: 11); + multiTerminal.write(Uint8List.fromList('\x1b[1;2H'.codeUnits)); + writePlacedImage(multiTerminal, id: 12, placementId: 12); + controlledPlacements.sync(multiTerminal, geometryDirty: true); + callbacks[0](await testImage()); + callbacks[1](await testImage()); + controlledPlacements.sync(multiTerminal, geometryDirty: false); + + multiTerminal.write( + Uint8List.fromList( + '\x1b_Ga=d,d=I,i=11\x1b\\\x1b_Ga=d,d=I,i=12\x1b\\'.codeUnits, + ), + ); + multiTerminal.write(Uint8List.fromList('\x1b[1;1H'.codeUnits)); + writePlacedImage(multiTerminal, id: 20, placementId: 20); + multiTerminal.write(Uint8List.fromList('\x1b[1;2H'.codeUnits)); + writePlacedImage(multiTerminal, id: 10, placementId: 10); + + controlledPlacements.sync(multiTerminal, geometryDirty: true); + + expect( + controlledPlacements.snapshots.map((snapshot) => snapshot.imageId), + [11, 12], + ); + }, + ); + + test('orders equal-z placements by ascending image id', () { + final equalZTerminal = Terminal(cols: 8, rows: 4) + ..kittyImageStorageLimit = 1 << 20; + final equalZState = PaintState(TerminalTheme.dark(), metrics) + ..cols = 8 + ..rows = 4; + final equalZImages = KittyImageCache(onImageReady: () {}); + final equalZPlacements = KittyPlacementCache( + state: equalZState, + images: equalZImages, + ); + addTearDown(equalZImages.dispose); + addTearDown(equalZTerminal.dispose); + final payload = base64Encode([0xff, 0x00, 0x00]); + + equalZTerminal.write( + Uint8List.fromList( + '\x1b_Gf=24,s=1,v=1,a=T,i=2,p=1,c=1,r=1;$payload\x1b\\'.codeUnits, + ), + ); + equalZTerminal.write( + Uint8List.fromList( + '\x1b_Gf=24,s=1,v=1,a=T,i=11,p=1,c=1,r=1;$payload\x1b\\'.codeUnits, + ), + ); + equalZTerminal.write( + Uint8List.fromList( + '\x1b_Gf=24,s=1,v=1,a=T,i=13,p=1,c=1,r=1;$payload\x1b\\'.codeUnits, + ), + ); + equalZTerminal.resize( + cols: 8, + rows: 4, + cellWidthPx: 8, + cellHeightPx: 16, + ); + + equalZPlacements.sync(equalZTerminal, geometryDirty: true); + + expect(equalZPlacements.snapshots.map((snapshot) => snapshot.imageId), [ + 2, + 11, + 13, + ]); + }); }); }); } 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..dfaba7f2 100644 --- a/packages/flterm/test/rendering/painters/cursor_painter_test.dart +++ b/packages/flterm/test/rendering/painters/cursor_painter_test.dart @@ -9,7 +9,7 @@ import 'package:flterm/src/rendering/atlas/atlas.dart'; import 'package:flterm/src/rendering/paint_state.dart'; import 'package:flterm/src/rendering/painters/cursor_painter.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' show Cursor; +import 'package:libghostty/libghostty.dart' show RenderStateCursor; void main() { group('CursorPainter', () { @@ -30,10 +30,10 @@ 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() + ..cursor = const RenderStateCursor(viewportHasValue: true) ..cursorColorArgb = 0xFF0000FF ..preeditActive = preeditActive; 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 5e295dce..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,6 +84,8 @@ void main() { double? maxWidth, double? maxHeight, }) { + final frameSource = FrameSource(terminal); + addTearDown(frameSource.dispose); final width = maxWidth ?? cols * metrics.cellWidth; final height = maxHeight ?? rows * metrics.cellHeight; return Directionality( @@ -93,12 +95,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(), + atlasPool: atlasPool(), + focused: true, + onGeometryChanged: (_) {}, + onViewportRowChanged: (_) {}, ), ), ), @@ -117,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( @@ -266,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' @@ -305,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( @@ -324,16 +331,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_renderer_golden_test.dart b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart index 66203eb1..1be422e4 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,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 = FrameSource(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), + atlasPool: atlasPool(), + 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: (_) {}, ), ), ), @@ -163,7 +180,7 @@ void main() { '\x1b[2mFaint text\x1b[0m\r\n' '\x1b[7mInverse text\x1b[0m\r\n' '\x1b[42;30m BG color \x1b[0m\r\n' - 'a => b != c === d', + '== === !== != -> =>', ); tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( @@ -181,6 +198,31 @@ void main() { ); terminal.dispose(); }); + + testWidgets('Nerd Font symbol spacing', (tester) async { + const cols = 7; + const rows = 1; + final terminal = Terminal(cols: cols, rows: rows); + writeUtf8(terminal, '\x1b[34m\uE5FF\x1b[0m fvm'); + final nerdTheme = theme.copyWith( + fontFamilyFallback: bundledNerdFontFamilyFallback, + ); + tester.view.devicePixelRatio = 1.0; + await tester.pumpWidget( + wrap( + terminal, + theme: nerdTheme, + metrics: goldenMetrics, + maxWidth: cols * goldenMetrics.cellWidth, + maxHeight: rows * goldenMetrics.cellHeight, + ), + ); + await expectLater( + find.byType(TerminalRenderer), + matchesGoldenFile('goldens/text_nerd_font_symbol_spacing.png'), + ); + terminal.dispose(); + }); }); group('text decorations', () { @@ -978,16 +1020,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..3482e53e 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,28 +28,32 @@ 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( 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, - TerminalRenderCache? renderCache, + double devicePixelRatio = 1, + ValueChanged? onGeometryChanged, + ValueChanged? onViewportRowChanged, + AtlasPool? atlasPool, ViewportOffset? offset, }) { selection?.applyTo(terminal); - renderCache ??= createRenderCache(); + atlasPool ??= createAtlasPool(); + final frameSource = FrameSource(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), + atlasPool: atlasPool, + 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,53 +136,141 @@ 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 { + SurfaceMeasurement? 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(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, + const TerminalGeometry( + cols: defaultCols, + rows: defaultRows, + widthPx: defaultCols * 8, + heightPx: defaultRows * 16, ), ); - expect(reportedCols, defaultCols); - expect(reportedRows, defaultRows); }); 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 { @@ -231,47 +335,30 @@ 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]); }); }); } -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); } } -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..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) { @@ -49,6 +49,9 @@ void main() { }) async { final terminal = Terminal(cols: cols, rows: rows); addTearDown(terminal.dispose); + applyTerminalTheme(terminal, theme); + final frameSource = FrameSource(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(), + atlasPool: atlasPool(), + 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/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/widgets/terminal_shortcut_scope_test.dart b/packages/flterm/test/view/shortcut_scope_test.dart similarity index 89% rename from packages/flterm/test/widgets/terminal_shortcut_scope_test.dart rename to packages/flterm/test/view/shortcut_scope_test.dart index 47470060..c7babc6e 100644 --- a/packages/flterm/test/widgets/terminal_shortcut_scope_test.dart +++ b/packages/flterm/test/view/shortcut_scope_test.dart @@ -3,14 +3,15 @@ library; import 'dart:convert'; -import 'package:flterm/src/widgets.dart'; +import 'package:flterm/src/controller/terminal_controller.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(() { @@ -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, @@ -30,7 +35,7 @@ void main() { }) { return Directionality( textDirection: TextDirection.ltr, - child: TerminalShortcutScope( + child: ShortcutScope( controller: controller, shortcuts: shortcuts, onPaste: 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; @@ -197,7 +202,7 @@ void main() { }); }); - group('TerminalShortcuts', () { + group('DefaultShortcuts', () { group('defaultsFor', () { void expectShortcutSet( Map shortcuts, { @@ -214,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), @@ -231,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), @@ -241,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/widgets/terminal_scroll_controller_test.dart b/packages/flterm/test/view/terminal_scroll_controller_test.dart similarity index 92% rename from packages/flterm/test/widgets/terminal_scroll_controller_test.dart rename to packages/flterm/test/view/terminal_scroll_controller_test.dart index c73d2155..6b62fcd6 100644 --- a/packages/flterm/test/widgets/terminal_scroll_controller_test.dart +++ b/packages/flterm/test/view/terminal_scroll_controller_test.dart @@ -1,7 +1,7 @@ -import 'package:flterm/src/widgets.dart'; -import 'package:flutter/material.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'; void main() { Widget buildScrollable( @@ -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()); diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/view/terminal_view_test.dart similarity index 52% rename from packages/flterm/test/widgets/terminal_view_test.dart rename to packages/flterm/test/view/terminal_view_test.dart index 9259d079..76f24567 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/view/terminal_view_test.dart @@ -1,22 +1,26 @@ 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, 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:libghostty/libghostty.dart' hide ColorScheme, KeyEvent; +import 'package:material_ui/material_ui.dart'; extension _SelectionEdges on Selection { Position get _startPoint => start.positionIn(.viewport)!; @@ -29,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 { @@ -66,12 +68,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) { @@ -80,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', @@ -127,6 +139,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 +155,7 @@ void main() { Widget wrapInApp({ required TerminalController controller, + FocusNode? focusNode, TerminalTheme? theme, TerminalScrollController? scrollController, bool autofocus = false, @@ -144,6 +166,7 @@ void main() { EdgeInsets padding = EdgeInsets.zero, double width = 800, double height = 480, + Uint8List? fontData, }) { return MaterialApp( home: Scaffold( @@ -152,6 +175,7 @@ void main() { height: height, child: TerminalView( controller: controller, + focusNode: focusNode, theme: theme, scrollController: scrollController, autofocus: autofocus, @@ -160,6 +184,7 @@ void main() { gestureSettings: gestureSettings, linkSettings: linkSettings, padding: padding, + fontData: fontData, ), ), ), @@ -249,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(); @@ -299,19 +324,127 @@ void main() { expect(rows.last, greaterThan(0)); }); - testWidgets('tap to focus', (tester) async { + testWidgets('sizes a pinned Kitty placement during first layout', ( + tester, + ) async { + final kittyController = TerminalController( + config: const TerminalConfig(cursorBlink: false), + ); + addTearDown(kittyController.dispose); + final fontData = File( + 'test/fixtures/fonts/JetBrainsMono-Regular.ttf', + ).readAsBytesSync(); + const payload = '/wAA'; + kittyController.write( + Uint8List.fromList( + '\x1b_Gf=24,s=1,v=1,a=T,i=1,c=1,r=1;$payload\x1b\\'.codeUnits, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 480, + child: TerminalView( + controller: kittyController, + padding: EdgeInsets.zero, + fontData: fontData, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final nativeTerminal = terminal(kittyController); + final placement = KittyGraphics.of(nativeTerminal)!.placements().single; + final geometry = nativeTerminal.geometry; + expect( + (placement.renderInfo.pixelWidth, placement.renderInfo.pixelHeight), + (geometry.widthPx ~/ geometry.cols, geometry.heightPx ~/ geometry.rows), + ); + }); + + 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('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); + 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 +453,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 +482,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 +501,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 +509,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 +747,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 +838,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 +989,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 +1029,7 @@ void main() { ), ); await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); calls.clear(); output.clear(); @@ -783,6 +1037,7 @@ void main() { LogicalKeyboardKey.backspace, ); await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.capsLock); expect(handled, isFalse); expect(decodeOutput(output), '\x08'); @@ -912,13 +1167,43 @@ void main() { expect(expected.top, greaterThan(0)); }); - testWidgets('unmount clears focus state', (tester) async { - await tester.pumpWidget(wrapInApp(controller: controller)); + 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); + }); + + 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'); + }); - expect(controller.hasFocus, isFalse); + 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 { @@ -941,6 +1226,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 +1287,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 +1438,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 +1508,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 { @@ -1215,131 +1655,1048 @@ void main() { types: const {LinkType.text}, onActivate: (_) {}, ), - width: 400, - height: 80, + width: 400, + height: 80, + ), + ); + await tester.pumpAndSettle(); + + final topLeft = tester.getTopLeft(find.byType(TerminalView)); + addTearDown(() => releaseControlIfPressed(tester)); + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: topLeft + const Offset(4, 8)); + await gesture.moveTo(topLeft + const Offset(4, 8)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.pump(); + + expect( + renderer(tester).linkSnapshot.highlighted, + const CellRange( + start: Position(row: 0, col: 0), + end: Position(row: 0, col: 19), + ), + ); + }); + + testWidgets('touch tap activates link without modifier', (tester) async { + final links = []; + writeUtf8(controller, 'https://example.test'); + + await tester.pumpWidget( + wrapInApp( + controller: controller, + linkSettings: LinkSettings( + types: const {LinkType.text}, + onActivate: links.add, + ), + width: 400, + height: 80, + ), + ); + await tester.pumpAndSettle(); + + final topLeft = tester.getTopLeft(find.byType(TerminalView)); + await tester.tapAt(topLeft + const Offset(4, 8)); + await tester.pump(); + + expect(links, [ + isA().having( + (link) => link.text, + 'text', + 'https://example.test', + ), + ]); + }); + }); + + testWidgets('long press starts normal selection by default', ( + tester, + ) async { + writeUtf8(controller, 'hello world'); + await tester.pumpWidget( + wrapInApp(controller: controller, autofocus: true), + ); + await tester.pumpAndSettle(); + + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = await tester.startGesture(center); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.moveBy(const Offset(80, 40)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final sel = activeSelection(controller); + expect(sel, isNotNull); + expect(sel!.mode, TerminalSelectionShape.normal); + }); + + group('scrolling', () { + Future< + ({ + TerminalScrollController scrollController, + Terminal terminal, + double cellHeight, + }) + > + pumpScrollableTerminal(WidgetTester tester) async { + final scrollController = TerminalScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + wrapInApp( + controller: controller, + scrollController: scrollController, + showKeyboard: false, + width: 400, + height: 80, + ), + ); + await tester.pump(); + writeNumberedLines(80); + await tester.pump(); + await tester.pump(); + + return ( + scrollController: scrollController, + terminal: terminal(controller), + cellHeight: renderer(tester).metrics.cellHeight, + ); + } + + List sgrMouseCodes(List output) { + return RegExp('\x1b\\[<(\\d+);') + .allMatches(decodeOutput(output)) + .map((match) => int.parse(match.group(1)!)) + .toList(); + } + + 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.pump(); + + final position = scrollController.position; + expect( + (position as ScrollbackPosition).activeScreen, + TerminalScreen.alternate, + ); + expect(position.minScrollExtent, double.negativeInfinity); + expect(position.maxScrollExtent, double.infinity); + }); + + testWidgets('restores primary scroll state after alternate screen', ( + tester, + ) async { + final fixture = await pumpScrollableTerminal(tester); + 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 topLeft = tester.getTopLeft(find.byType(TerminalView)); - addTearDown(() => releaseControlIfPressed(tester)); - final gesture = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - addTearDown(gesture.removePointer); - await gesture.addPointer(location: topLeft + const Offset(4, 8)); - await gesture.moveTo(topLeft + const Offset(4, 8)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + 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( - renderer(tester).linkSnapshot.highlighted, - const CellRange( - start: Position(row: 0, col: 0), - end: Position(row: 0, col: 19), - ), - ); + expect(focusNode.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); }); - testWidgets('touch tap activates link without modifier', (tester) async { - final links = []; - writeUtf8(controller, 'https://example.test'); + 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( - wrapInApp( - controller: controller, - linkSettings: LinkSettings( - types: const {LinkType.text}, - onActivate: links.add, + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 480, + child: Row( + children: [ + Expanded(child: TerminalView(controller: controller)), + Expanded(child: TerminalView(controller: controller2)), + ], + ), + ), ), - width: 400, - height: 80, ), ); await tester.pumpAndSettle(); - - final topLeft = tester.getTopLeft(find.byType(TerminalView)); - await tester.tapAt(topLeft + const Offset(4, 8)); + 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(links, [ - isA().having( - (link) => link.text, - 'text', - 'https://example.test', - ), - ]); + expect(routing, (startReceived: true, otherReceived: false)); }); - }); - testWidgets('long press starts normal selection by default', ( - tester, - ) async { - writeUtf8(controller, 'hello world'); - await tester.pumpWidget( - wrapInApp(controller: controller, autofocus: true), - ); - await tester.pumpAndSettle(); + testWidgets('touch drag starts while prior momentum is active', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); - final center = tester.getCenter(find.byType(TerminalView)); - final gesture = await tester.startGesture(center); - await tester.pump(const Duration(milliseconds: 600)); - await gesture.moveBy(const Offset(80, 40)); - await tester.pump(); - await gesture.up(); - 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)), + ); - final sel = activeSelection(controller); - expect(sel, isNotNull); - expect(sel!.mode, TerminalSelectionShape.normal); - }); + expect(restarted, isTrue); + }); - group('scrolling', () { - Future< - ({ - TerminalScrollController scrollController, - Terminal terminal, - double cellHeight, - }) - > - pumpScrollableTerminal(WidgetTester tester) async { - final scrollController = TerminalScrollController(); - addTearDown(scrollController.dispose); + testWidgets('touch contact immediately holds active momentum', ( + tester, + ) async { + await pumpScrollableTerminal(tester); + writeUtf8(controller, '\x1b[?1049h'); + await tester.pump(); - await tester.pumpWidget( - wrapInApp( - controller: controller, - scrollController: scrollController, - showKeyboard: false, - width: 400, - height: 80, + 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(); - writeNumberedLines(80); + 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); - return ( - scrollController: scrollController, - terminal: terminal(controller), - cellHeight: renderer(tester).metrics.cellHeight, + 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); - testWidgets('scroll event changes scroll offset', (tester) async { + 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; - final center = tester.getCenter(find.byType(TerminalView)); + 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( - PointerScrollEvent( - position: center, - scrollDelta: const Offset(0, -100), - ), + 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)); }); @@ -1356,6 +2713,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 +2850,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()); @@ -1669,79 +3056,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/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/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 ef968f6c..5e5d8b4f 100644 --- a/packages/flterm/tool/benchmarks/frame/harness.dart +++ b/packages/flterm/tool/benchmarks/frame/harness.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; -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'; @@ -45,10 +46,13 @@ final class FrameBenchmarkHarness { ( terminal: Terminal(cols: benchmarkColumns, rows: benchmarkRows) ..write(state), - cache: TerminalRenderCache(), + atlasPool: AtlasPool(), ), ]; - final retainedAtlases = []; + final frameSources = [ + for (final resource in resources) FrameSource(resource.terminal), + ]; + final retainedAtlasLeases = []; try { await _tester.pumpWidget(const SizedBox.shrink()); await _binding.watchPerformance(() async { @@ -58,14 +62,14 @@ final class FrameBenchmarkHarness { _binding.wrapWithDefaultView( BenchmarkTerminalSurface( key: ValueKey(sample), - terminal: resource.terminal, - cache: resource.cache, + frameSource: frameSources[sample], + atlasPool: resource.atlasPool, ), ), ); _binding.scheduleFrame(); await _binding.endOfFrame; - retainedAtlases.add(retainBenchmarkAtlas(resource.cache)); + retainedAtlasLeases.add(retainBenchmarkAtlas(resource.atlasPool)); } }, reportKey: _firstFrameReportKey); final summary = Map.from( @@ -77,11 +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(); } } @@ -99,13 +106,15 @@ final class FrameBenchmarkHarness { List? updates, }) async { final terminal = Terminal(cols: benchmarkColumns, rows: benchmarkRows); - final cache = TerminalRenderCache(); + final frameSource = FrameSource(terminal); + final atlasPool = AtlasPool(); addTearDown(terminal.dispose); - addTearDown(cache.dispose); + addTearDown(frameSource.dispose); + addTearDown(atlasPool.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(terminal: terminal, cache: cache), + BenchmarkTerminalSurface(frameSource: frameSource, atlasPool: atlasPool), ); 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 cache = TerminalRenderCache(); + final frameSource = FrameSource(terminal); + final atlasPool = AtlasPool(); addTearDown(terminal.dispose); - addTearDown(cache.dispose); + addTearDown(frameSource.dispose); + addTearDown(atlasPool.dispose); addTearDown(() => _tester.pumpWidget(const SizedBox.shrink())); await _tester.pumpWidget( - BenchmarkTerminalSurface(terminal: terminal, 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 ec045e45..232760db 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_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'; 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,13 +71,13 @@ String benchmarkFontDigest({ /// Fixed terminal surface shared by every rendering workload. final class BenchmarkTerminalSurface extends StatelessWidget { - final Terminal terminal; - final TerminalRenderCache cache; + final FrameSource frameSource; + final AtlasPool atlasPool; const BenchmarkTerminalSurface({ super.key, - required this.terminal, - required this.cache, + required this.frameSource, + required this.atlasPool, }); @override @@ -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(), - renderCache: cache, + focused: true, + atlasPool: atlasPool, + 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, ), ), ), @@ -106,19 +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); -} - -final class _FocusedRenderObserver implements TerminalRenderObserver { - const _FocusedRenderObserver(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} +AtlasLease retainBenchmarkAtlas(AtlasPool atlasPool) { + return atlasPool.acquireAtlas(_atlasConfig); } diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 35c9b5f2..46b4cb8c 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 0.0.13 + +### Breaking + +- **Typed public values**: callback payloads, terminal geometry, decoded images, + and X11 colors use named, immutable classes. Replace record literals with the + corresponding class constructors. +- **Kitty placement types**: `Placement` and `RenderInfo` are replaced by + `KittyPlacement` and `KittyPlacementRenderInfo`. +- **Cursor types**: `Cursor` is renamed to `RenderStateCursor`. It replaces + `position` and `shape` with `viewportX`, `viewportY`, and `visualStyle`, and + adds `viewportHasValue`, `wideTail`, `blinking`, and `passwordInput`. + Terminal cursor configuration uses `TerminalCursorShape`. +- **Selection gesture options**: `SelectionGestureEvent.clear()` is replaced + by nullable setters such as `setRef(null)` and + `setWordBoundaryCodepoints(null)`. Use `clearPosition()` to clear the pointer + position. +- **Exception constructors**: messages and operation context use the named + `message` and `operation` arguments. +- **Kitty pixel data**: `KittyImage.pixelData` is replaced by + `copyPixelDataInto()`, which copies into caller-owned storage and returns the + written byte count. + +### Added + +- **Terminal parsing controls**: `writeUntilGround()`, `isVtGround`, + `isCursorAtPrompt`, and `terminfoName` expose VT parsing, prompt state, and + terminal capability configuration. +- **Unknown sequences**: `unknownSequenceMaxBytes` and `onUnknownSequence` + expose unsupported binary-safe terminal sequences with truncation metadata. + +### Changed + +- **Render traversal**: bulk Wasm cell reads and dirty-row iteration reduce + binding calls during incremental rendering. +- **Error details**: libghostty exceptions include the result code and operation + context. Unknown result codes are preserved by `UnknownResultException`. +- **Native and Wasm consistency**: both targets expose the same public behavior, + including error handling and resource lifetime semantics. + ## 0.0.12 ### Breaking diff --git a/packages/libghostty/README.md b/packages/libghostty/README.md index 95b36ddc..8f5285b5 100644 --- a/packages/libghostty/README.md +++ b/packages/libghostty/README.md @@ -19,7 +19,7 @@ dependencies: libghostty: ^0.0.12 ``` -On web, initialize the WASM module once before using any bindings: +On web, initialize the Wasm module once before using any libghostty API: ```dart await initializeForWeb(Uri.parse('assets/libghostty.wasm')); @@ -138,6 +138,18 @@ print(formatter.format()); formatter.dispose(); ``` +### Resource lifetime and errors + +Dispose owned resources such as `Terminal`, `RenderState`, iterators, encoders, +events, parsers, and formatters when they are no longer needed. Disposal is +idempotent. Borrowed values such as `GridRef`, `KittyGraphics`, and `KittyImage` +must not be used after a mutating terminal operation; reacquire them instead. + +Failures reported by libghostty use typed exceptions, including +`OutOfMemoryException`, `InvalidValueException`, `OutOfSpaceException`, +`NoValueException`, `IoException`, and `LimitExceededException`. Invalid Dart +resource lifecycle use throws `StateError`. + ### SGR and OSC parsing Parse SGR (Select Graphic Rendition) parameters into typed attributes and diff --git a/packages/libghostty/ghostty.version b/packages/libghostty/ghostty.version index fd6d9877..7da5967f 100644 --- a/packages/libghostty/ghostty.version +++ b/packages/libghostty/ghostty.version @@ -1 +1 @@ -4d605bf0d819df901a0332bbb320dc849fdd82e4 +b97b17f06b1ffd694f80edd3df5dd2134a0bcb9e diff --git a/packages/libghostty/lib/libghostty.dart b/packages/libghostty/lib/libghostty.dart index a1b699b9..e1affc32 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,96 @@ 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, + TerminalUnknownSequenceTag; export 'src/listenable.dart' show Listenable; +export 'src/types/aliases.dart' + show + ClipboardWriteCallback, + ContinuationWriter, + DesktopNotificationCallback, + PngDecoder, + SysLogCallback, + TerminalCursorShape, + TerminalProgressCallback, + TerminalUnknownSequenceCallback, + ValueGetter, + ValueSetter, + VoidCallback; +export 'src/types/types.dart' + show + CellColor, + CellWidth, + ClipboardContent, + ClipboardWrite, + 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, + RenderStateCursor, + RgbColor, + Scrollbar, + SemanticContent, + SemanticPrompt, + SgrAttribute, + Style, + TerminalColors, + TerminalGeometry, + TerminalProgress, + TerminalSizeInfo, + TerminalUnknownSequence, + 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 55% rename from packages/libghostty/lib/src/impl/terminal/cell_iterator.dart rename to packages/libghostty/lib/src/api/terminal/cell_iterator.dart index 5009aad1..3be901d6 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,17 +27,20 @@ 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; + RawCellsView? _rawCells; + var _rawCellsAvailable = false; + var _rowPositionGeneration = 0; + var _rawCell = const LibGhosttyHandle.fromAddress(0); + final _rawCellData = RawCellData(); var _graphemeLen = 0; - var _codepoint = 0; - var _styleId = -1; var _prevStyleId = -1; var _cachedStyle = const Style(); - var _wide = CellWidth.narrow; var _col = -1; var _isSelected = false; var _textValid = false; @@ -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,35 +60,57 @@ 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(); + if (_rawCellsAvailable) { + _ensureMetadata(); + if (_rawCellData.hasBackgroundRgb) { + return RgbColor( + _rawCellData.backgroundR, + _rawCellData.backgroundG, + _rawCellData.backgroundB, + ); + } + } + 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(); + if (_rawCellsAvailable) { + _ensureMetadata(); + if (_rawCellData.hasBackgroundRgb) { + return 0xFF000000 | + (_rawCellData.backgroundR << 16) | + (_rawCellData.backgroundG << 8) | + _rawCellData.backgroundB; + } + } + return bindings.render.rowCellsGetBgColorArgb(_handle); } /// Primary codepoint of the current cell, or 0 if the cell has no text. int get codepoint { _ensureText(); - return _codepoint; + return _rawCellData.codepoint; } /// 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. String get content { _ensureText(); if (_graphemeLen == 0) return ''; - if (_graphemeLen == 1) return String.fromCharCode(_codepoint); + if (_graphemeLen == 1) return String.fromCharCode(_rawCellData.codepoint); return String.fromCharCodes( - check(bindings.rowCellsGetGraphemes(_handle, _graphemeLen)), + bindings.render.rowCellsGetGraphemes(_handle, _graphemeLen), ); } @@ -91,14 +120,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 +140,19 @@ final class CellIterator { /// Whether the current cell has a hyperlink (OSC 8). bool get hasHyperlink { _ensureText(); - return check(bindings.cellGetHasHyperlink(_rawCell)); + if (_rawCellsAvailable) return _rawCellData.hasHyperlink; + 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(); + if (_rawCellsAvailable) { + _ensureMetadata(); + return _rawCellData.styleId != 0; + } + return bindings.render.rowCellsGetHasStyling(_handle); + } /// Whether the current cell contains any text. bool get hasText { @@ -126,7 +163,8 @@ final class CellIterator { /// Whether the current cell is protected (DECSCA). bool get isProtected { _ensureText(); - return check(bindings.cellGetProtected(_rawCell)); + if (_rawCellsAvailable) return _rawCellData.isProtected; + return bindings.render.cellGetProtected(_rawCell); } /// Whether the current cell is contained within the current selection. @@ -135,8 +173,10 @@ final class CellIterator { /// row-local selection range, and false otherwise. Rendering colors, /// inversion, etc are caller policy. bool get isSelected { + _ensureCurrent(); + if (_rawCellsAvailable) return _rawCells!.isSelected(_col); if (!_selectedValid) { - _isSelected = check(bindings.rowCellsGetSelected(_handle)); + _isSelected = bindings.render.rowCellsGetSelected(_handle); _selectedValid = true; } return _isSelected; @@ -145,16 +185,17 @@ final class CellIterator { /// Semantic content type of the current cell. SemanticContent get semanticContent { _ensureText(); - return check(bindings.cellGetSemanticContent(_rawCell)); + if (_rawCellsAvailable) return _rawCellData.semanticContent; + return bindings.render.cellGetSemanticContent(_rawCell); } /// Style of the current cell. Cached per style id to avoid redundant /// lookups across cells sharing the same style. Style get style { _ensureMetadata(); - if (_styleId != _prevStyleId) { - _prevStyleId = _styleId; - _cachedStyle = check(bindings.rowCellsGetStyle(_handle)); + if (_rawCellData.styleId != _prevStyleId) { + _prevStyleId = _rawCellData.styleId; + _cachedStyle = bindings.render.rowCellsGetStyle(_handle); } return _cachedStyle; } @@ -163,30 +204,30 @@ final class CellIterator { /// style id share identical styling attributes. int get styleId { _ensureMetadata(); - return _styleId; + return _rawCellData.styleId; } /// Cell width: [CellWidth.narrow], [CellWidth.wide], or /// [CellWidth.spacerTail] (the second cell of a wide character). CellWidth get wide { _ensureMetadata(); - return _wide; + return _rawCellData.wide; } /// 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 +240,16 @@ 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; + final rawCells = _rawCells ??= RawCellsView(); + _rawCellsAvailable = bindings.render.rowIteratorGetRawCells( + rowIterator._handle, + rawCells, + ); + _rowPositionGeneration = rowIterator._positionGeneration; _col = -1; _prevStyleId = -1; _invalidate(); @@ -213,16 +263,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,16 +302,33 @@ final class CellIterator { } void _refreshMetadata() { - final rowCell = check(bindings.rowCellsGetSummary(_handle)); - _rawCell = rowCell.rawCell; + if (_rawCellsAvailable) { + if (bindings.render.decodeRawCell(_rawCells!, _col, _rawCellData)) { + _rawCell = .fromAddress(_rawCellData.rawCell); + _graphemeLen = _rawCellData.hasGrapheme + ? bindings.render.rowCellsGetGraphemeLen(_handle) + : _rawCellData.codepoint == 0 + ? 0 + : 1; + _selectedValid = true; + _textValid = true; + _metadataValid = true; + return; + } + _rawCellsAvailable = false; + } + + final rowCell = bindings.render.rowCellsGetSummary(_handle); + _rawCell = .fromAddress(rowCell.rawCell); _graphemeLen = rowCell.graphemeLen; _isSelected = rowCell.selected; _selectedValid = true; - final cell = check(bindings.cellGetSummary(_rawCell)); - _styleId = cell.styleId; - _codepoint = _graphemeLen > 0 ? cell.codepoint : 0; - _wide = cell.wide; + final cell = bindings.render.cellGetSummary(_rawCell); + _rawCellData.rawCell = rowCell.rawCell; + _rawCellData.styleId = cell.styleId; + _rawCellData.codepoint = _graphemeLen > 0 ? cell.codepoint : 0; + _rawCellData.wide = cell.wide; _textValid = true; _metadataValid = true; } 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..5d53d29b --- /dev/null +++ b/packages/libghostty/lib/src/api/terminal/kitty_graphics.dart @@ -0,0 +1,192 @@ +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; +/// if (image.format != KittyImageFormat.rgba) continue; +/// final pixels = Uint8List(image.width * image.height * 4); +/// image.copyPixelDataInto(pixels); +/// // Draw `pixels` 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. +/// +/// [copyPixelDataInto] is the exception. It copies the bytes into Dart-owned +/// storage that 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 the stored pixel data. + KittyImageCompression get compression { + return bindings.kittyGraphics.kittyGraphicsImageGetCompression(_handle); + } + + /// Format of the stored pixel data. + 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); + + /// Image width in pixels. + int get width => bindings.kittyGraphics.kittyGraphicsImageGetWidth(_handle); + + /// Copies the raw pixel bytes into [destination] and returns the byte count. + /// + /// Stored images are already decoded and decompressed. PNG payloads are + /// decoded through the callback installed via [LibGhostty.setPngDecoder] + /// and copied as RGBA. The destination must be large enough for the complete + /// payload; otherwise a [RangeError] is thrown. The copy completes + /// synchronously, so the written bytes remain valid after subsequent + /// terminal mutations. + int copyPixelDataInto(Uint8List destination) { + return bindings.kittyGraphics.kittyGraphicsImageGetPixelData( + _handle, + destination, + ); + } +} diff --git a/packages/libghostty/lib/src/impl/terminal/render_state.dart b/packages/libghostty/lib/src/api/terminal/render_state.dart similarity index 62% rename from packages/libghostty/lib/src/impl/terminal/render_state.dart rename to packages/libghostty/lib/src/api/terminal/render_state.dart index 8900419a..1b1f1426 100644 --- a/packages/libghostty/lib/src/impl/terminal/render_state.dart +++ b/packages/libghostty/lib/src/api/terminal/render_state.dart @@ -32,20 +32,23 @@ enum DirtyState { /// Construct once, call [update] each frame to refresh the snapshot from a /// [Terminal], then iterate rows and cells using [RowIterator] / [CellIterator] /// bound with [RowIterator.reset] / [CellIterator.reset]. After rendering, -/// clear the per-row dirty flags via [RowIterator.dirty] and set [dirty] back -/// to [DirtyState.clean]. When done with the state entirely, call [dispose]. +/// clear the global and per-row dirty flags with [clean]. When done with the +/// state entirely, call [dispose]. /// /// The key design principle is that the render state only needs access to the /// [Terminal] during the [update] call. Between updates, all data can be read /// 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 /// and per-row flags via [RowIterator.dirty]. [update] sets these but does -/// not clear them. After rendering, set [dirty] back to [DirtyState.clean] -/// and clear each processed row's flag via [RowIterator.dirty]. +/// not clear them. After rendering a complete frame, call [clean]. For a +/// partial frame, use [RowIterator.dirty] to clear only the rows consumed. /// /// ```dart /// final renderState = RenderState(); @@ -65,14 +68,15 @@ enum DirtyState { /// } /// rows.dirty = false; /// } -/// renderState.dirty = DirtyState.clean; +/// renderState.clean(); /// } /// ``` 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,38 @@ 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; - - /// 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, - ); + int get cols { + _ensureAlive(); + return _cols; + } + + /// Cursor state from the last [update]. + /// + /// Check [RenderStateCursor.viewportHasValue] before reading its viewport + /// coordinates or wide-tail state. Viewport presence is independent of + /// [RenderStateCursor.visible], which reflects terminal cursor modes. + RenderStateCursor get cursor { + _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 +125,37 @@ 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. + /// Clears the global dirty state and every per-row dirty flag. /// - /// Must be called to free resources; the render state must not be used - /// afterward. + /// Call this after a complete frame has been rendered. Use [dirty] and + /// [RowIterator.dirty] when only part of a frame has been consumed. + void clean() { + _ensureAlive(); + bindings.render.renderStateClean(_handle); + _dirty = .clean; + } + + /// Releases the native render state handle. 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. @@ -153,21 +163,27 @@ final class RenderState { /// After this call, all render state properties ([cols], [rows], [colors], /// [cursor]) reflect the terminal's current state, and [dirty] indicates /// what changed. Does not clear this render state's own dirty tracking; - /// after rendering, set [dirty] to [DirtyState.clean] and clear per-row - /// flags via [RowIterator.dirty] during the render loop. + /// after rendering a complete frame, call [clean]. For partial rendering, + /// clear only consumed rows via [RowIterator.dirty]. /// /// 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 56% rename from packages/libghostty/lib/src/impl/terminal/row_iterator.dart rename to packages/libghostty/lib/src/api/terminal/row_iterator.dart index f64f95f1..78636ec5 100644 --- a/packages/libghostty/lib/src/impl/terminal/row_iterator.dart +++ b/packages/libghostty/lib/src/api/terminal/row_iterator.dart @@ -13,20 +13,28 @@ 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(); /// /// rows.reset(renderState); -/// while (rows.next()) { -/// if (rows.dirty) print('row ${rows.index} changed'); +/// while (rows.nextDirty()) { +/// renderRow(rows.index); /// } /// ``` 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,44 +126,94 @@ 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; } + /// Advances to the next row requiring a redraw. + /// + /// Rows are visited in ascending viewport order. A clean render state + /// produces no rows, a partially dirty state skips clean rows, and a fully + /// dirty state visits every remaining row. The iterator remains positioned + /// on the returned row, just like [next]. + bool nextDirty() { + _ensureCurrent(); + final index = bindings.render.rowIteratorNextDirty(_handle); + _positionGeneration++; + if (index case final value?) { + _rowSummaryValid = false; + _index = value; + _positioned = true; + return true; + } + _rowSummaryValid = false; + _positioned = false; + return false; + } + /// Rebinds this iterator to [renderState] and rewinds to the start. /// /// The render state must have been populated via [RenderState.update]. /// 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 57% rename from packages/libghostty/lib/src/impl/terminal/terminal.dart rename to packages/libghostty/lib/src/api/terminal/terminal.dart index eb885036..8e704006 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'; @@ -48,17 +49,22 @@ part 'tracked_grid_ref.dart'; /// ## Effects /// /// Effects are callbacks invoked synchronously by terminal operations. Most -/// respond to VT sequences during [write], while [onWritePty] can also fire -/// during a callback-emitting [resize]. Register them by assigning the callback -/// setters ([onWritePty], [onBell], [onTitleChanged], etc.). Set to null to -/// disable. +/// respond to VT sequences during [write] or [writeUntilGround], while +/// [onWritePty] can also fire during a callback-emitting [resize]. Register +/// them by assigning the callback 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 +/// Callbacks run synchronously. An effect invoked while [write] or +/// [writeUntilGround] is processing VT input must not call either method 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]. +/// /// ## Color Theme /// /// The terminal maintains two color layers for foreground, background, cursor, @@ -71,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); /// @@ -83,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. @@ -101,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); } @@ -111,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). @@ -119,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. @@ -127,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. @@ -150,41 +156,79 @@ 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. + /// + /// 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 bindings.terminal.terminalContinuationGet(_terminalHandle); + } + + /// 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 bindings.terminal.terminalGetContinuationMaxBytes(_terminalHandle); + } + + /// 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'); + 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). @@ -192,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. @@ -217,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. /// @@ -231,49 +276,61 @@ 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 cursor is at the semantic prompt boundary. + /// + /// The value is false when prompt tracking is unavailable or when the + /// cursor is on the alternate screen. + bool get isCursorAtPrompt { + return bindings.terminal.terminalGetCursorAtPrompt(_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); } + /// Whether the VT parser is in its ground state. + /// + /// This is true when no escape, control, or UTF-8 sequence is pending and + /// the next byte can be processed from a stateless boundary. + bool get isVtGround => bindings.terminal.terminalGetVtGround(_terminalHandle); + /// Kitty image storage limit in bytes for the active screen. /// /// 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. @@ -282,7 +339,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. @@ -294,8 +351,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. @@ -314,14 +372,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) { @@ -331,7 +392,15 @@ 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). + /// + /// Return the current [ColorScheme], or null to silently ignore the query. + /// Fires synchronously during [write]. + set onColorScheme(ValueGetter? value) { + bindings.terminal.terminalSetOnColorScheme(_terminalHandle, value); } /// Registers a callback for OSC 9 and OSC 777 desktop notifications. @@ -340,22 +409,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); - } - - /// 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); + bindings.terminal.terminalSetOnDesktopNotification(_terminalHandle, value); } /// Registers a callback for device attributes queries (CSI c / > c / = c). @@ -363,7 +417,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). @@ -371,15 +425,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.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). @@ -387,25 +449,48 @@ 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 unsupported string sequences. + /// + /// Capture must be enabled with [unknownSequenceMaxBytes]. The callback + /// receives copied, binary-safe content and runs synchronously during + /// [write] or [writeUntilGround]. Only normally terminated sequences with + /// unsupported identifiers are reported. Aborted sequences, malformed + /// recognized commands, and disabled known protocols are ignored. The + /// callback must not call [write] or [writeUntilGround] for this terminal. + /// Set to null to ignore captured sequences. + /// + /// ```dart + /// terminal.unknownSequenceMaxBytes = 4096; + /// terminal.onUnknownSequence = (sequence) { + /// if (sequence.tag == TerminalUnknownSequenceTag.apc) { + /// inspectApc(sequence.content, truncated: sequence.truncated); + /// } + /// }; + /// ``` + set onUnknownSequence(TerminalUnknownSequenceCallback? value) { + bindings.terminal.terminalSetOnUnknownSequence(_terminalHandle, value); } /// Registers a callback for PTY write-back data. /// /// 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); + bindings.terminal.terminalSetOnWritePty(_terminalHandle, value); } /// Registers a callback for XTVERSION queries (CSI > q). @@ -414,17 +499,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 @@ -433,33 +516,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. @@ -467,7 +553,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. @@ -475,7 +561,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. @@ -483,11 +569,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. /// @@ -498,17 +585,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. @@ -517,28 +603,58 @@ 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(); } + /// Sets the terminfo name used to answer XTGETTCAP `TN` queries. + /// + /// The name is copied by libghostty. Set to null or an empty string to + /// leave `TN` queries unanswered. Names longer than 128 UTF-8 bytes throw + /// [InvalidValueException]. + set terminfoName(String? value) { + bindings.terminal.terminalSetTerminfoName(_terminalHandle, value); + } + /// 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); + + /// Maximum bytes captured for unsupported string sequences. + /// + /// Set to null or zero to disable capture. The limit applies to sequence + /// content. The callback reports truncation when the limit is exceeded or + /// the complete content cannot be allocated. + set unknownSequenceMaxBytes(int? value) { + if (value != null) RangeError.checkNotNegative(value, 'value'); + bindings.terminal.terminalSetUnknownSequenceMaxBytes( + _terminalHandle, + value, + ); + } /// 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. /// @@ -560,20 +676,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. @@ -586,31 +703,46 @@ 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]. + /// + /// Some transition or mirrored modes cannot be configured as reset defaults + /// and throw [InvalidValueException]. + void modeSetDefault(TerminalMode mode, {required bool 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. /// @@ -634,10 +766,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, @@ -650,12 +781,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. /// @@ -668,17 +800,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. @@ -686,10 +820,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]. @@ -701,15 +833,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]. @@ -717,13 +847,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]. @@ -731,14 +859,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. @@ -750,15 +876,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. @@ -766,12 +890,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 @@ -781,49 +908,64 @@ 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. + /// + /// 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}) { + 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] or + /// [writeUntilGround] 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 @@ -834,7 +976,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(); @@ -845,6 +987,59 @@ 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); + } + + /// Writes bytes until the parser reaches the ground state. + /// + /// Returns the number of bytes consumed when ground is reached, including + /// zero when the parser was already in ground. When the returned count is + /// less than `data.length`, the remaining suffix was not processed. Returns + /// null when all bytes were consumed without reaching ground. Callbacks and + /// listener notifications follow the same rules as [write]. Callbacks must + /// not call [write] or [writeUntilGround] on this terminal. + /// + /// ```dart + /// final consumed = terminal.writeUntilGround(data); + /// if (consumed != null) { + /// handleGroundBoundary(); + /// terminal.write(Uint8List.sublistView(data, consumed)); + /// } + /// ``` + int? writeUntilGround(Uint8List data) { + late final int? consumed; + try { + consumed = bindings.terminal.terminalWriteUntilGround( + _terminalHandle, + data, + ); + } on Object catch (error, stackTrace) { + try { + notifyListeners(); + } finally { + Error.throwWithStackTrace(error, stackTrace); + } + } + notifyListeners(); + return consumed; + } + RawGridRef _checkedRef(GridRef ref, [String name = 'ref']) { _checkRefTerminal(ref, name); return ref._value; @@ -867,8 +1062,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..70eb82cd --- /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.allocateBytes(_formatBufferCapacity), + ); + _written = _requirePointer(_exports.allocateBytes(4)); + } + + @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(_exports.ghostty_wasm_take_opaque(out)); + } finally { + frame.release(); + } + } + + void _growFormatBuffer(int required) { + if (required <= _formatBufferCapacity) return; + final replacement = _requirePointer(_exports.allocateBytes(required)); + _exports.freeBytes(_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 905e14f7..00000000 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ /dev/null @@ -1,526 +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}); - 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); - 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