diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index b163bd55..21ec37a8 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -24,6 +24,13 @@ measured grid; assigning it later immediately reports that grid. Cell-pixel-only changes skip the callback, and in-band output is emitted first. +- **Layout-aware keyboard input**: the desktop plugin companions now supply + native keyboard metadata (modifiers, consumed modifiers, and the active + layout's unshifted codepoint) so terminal protocol encoding covers non-US + layouts, AltGr/Option, dead keys, lock state, repeats, and releases. + `TerminalConfig.optionAsAlt` makes macOS Option act as a side-aware terminal + Alt modifier, and `TerminalController` accepts a `keyEventNormalizer` for + runners with their own metadata pipeline. ### Fixed diff --git a/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a..07d7e398 100644 --- a/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import flterm func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FltermPlugin.register(with: registry.registrar(forPlugin: "FltermPlugin")) } diff --git a/packages/flterm/lib/flterm.dart b/packages/flterm/lib/flterm.dart index fb121de2..b6bce10e 100644 --- a/packages/flterm/lib/flterm.dart +++ b/packages/flterm/lib/flterm.dart @@ -19,8 +19,10 @@ export 'package:libghostty/libghostty.dart' FormatterExtra, FormatterFormat, Key, + KeyAction, Mods, MouseTracking, + OptionAsAlt, PointTag, Position, Scrollbar, @@ -47,6 +49,8 @@ export 'src/foundation/terminal_gesture_settings.dart' LineSelectMode, TerminalGestureSettings, TerminalSelectionShape; +export 'src/foundation/terminal_keyboard_event.dart' + show TerminalKeyEventNormalizer, TerminalKeyboardEvent; export 'src/foundation/terminal_theme.dart' show CursorTheme, diff --git a/packages/flterm/lib/src/controller/terminal_controller.dart b/packages/flterm/lib/src/controller/terminal_controller.dart index 378efd74..94856694 100644 --- a/packages/flterm/lib/src/controller/terminal_controller.dart +++ b/packages/flterm/lib/src/controller/terminal_controller.dart @@ -48,7 +48,17 @@ abstract class TerminalController extends ChangeNotifier { /// /// The terminal is created immediately with the initial dimensions, modes, /// resource limits, and other behavior from [config]. - factory TerminalController({TerminalConfig config}) = TerminalControllerImpl; + /// The terminal is created immediately with dimensions and scrollback + /// from [config]. Disposed when the controller is disposed. + /// + /// [keyEventNormalizer] can enrich or replace the normalized keyboard event + /// before terminal protocol encoding. flterm already supplies native layout + /// metadata on desktop when its plugin is registered; custom runners can use + /// this callback for metadata captured in their own event pipeline. + factory TerminalController({ + TerminalConfig config, + TerminalKeyEventNormalizer? keyEventNormalizer, + }) = TerminalControllerImpl; @internal TerminalController.base(); diff --git a/packages/flterm/lib/src/controller/terminal_controller_impl.dart b/packages/flterm/lib/src/controller/terminal_controller_impl.dart index d7be7b7b..097d4430 100644 --- a/packages/flterm/lib/src/controller/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/controller/terminal_controller_impl.dart @@ -43,11 +43,14 @@ final class TerminalControllerImpl extends TerminalController { 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() { + final TerminalKeyEventNormalizer? keyEventNormalizer; + + TerminalControllerImpl({ + TerminalConfig config = const TerminalConfig(), + this.keyEventNormalizer, + }) : _config = config, + _terminal = Terminal(cols: config.cols, rows: config.rows), + super.base() { _inputEncoder = InputEncoder(_terminal); _selection = SelectionSession(_terminal, notifyListeners); installDefaultKittyPngDecoder(); diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index e98c0a9f..9a9a55e8 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -7,4 +7,5 @@ export 'foundation/platform_map.dart'; export 'foundation/surface_geometry.dart'; export 'foundation/terminal_config.dart'; export 'foundation/terminal_gesture_settings.dart'; +export 'foundation/terminal_keyboard_event.dart'; export 'foundation/terminal_theme.dart'; diff --git a/packages/flterm/lib/src/foundation/platform_map.dart b/packages/flterm/lib/src/foundation/platform_map.dart index f59f0e87..8313a04d 100644 --- a/packages/flterm/lib/src/foundation/platform_map.dart +++ b/packages/flterm/lib/src/foundation/platform_map.dart @@ -1,5 +1,5 @@ import 'package:flutter/services.dart' show PhysicalKeyboardKey; -import 'package:libghostty/libghostty.dart' show Key; +import 'package:libghostty/libghostty.dart' show Key, Mods; final Map _codepointToKey = { 0x20: Key.space, @@ -197,6 +197,31 @@ final Map _keyToCodepoint = { Key.quote: 0x27, Key.semicolon: 0x3b, Key.slash: 0x2f, + Key.space: 0x20, +}; + +const _shiftedCodepoints = { + 0x60: 0x7e, + 0x31: 0x21, + 0x32: 0x40, + 0x33: 0x23, + 0x34: 0x24, + 0x35: 0x25, + 0x36: 0x5e, + 0x37: 0x26, + 0x38: 0x2a, + 0x39: 0x28, + 0x30: 0x29, + 0x2d: 0x5f, + 0x3d: 0x2b, + 0x5b: 0x7b, + 0x5d: 0x7d, + 0x5c: 0x7c, + 0x3b: 0x3a, + 0x27: 0x22, + 0x2c: 0x3c, + 0x2e: 0x3e, + 0x2f: 0x3f, }; /// Maps a Unicode [codepoint] to the corresponding libghostty [Key]. @@ -220,3 +245,25 @@ Key keyFromPhysical(PhysicalKeyboardKey physical) { /// Used by the key encoder to determine the unshifted codepoint that /// libghostty expects for keyboard input encoding. int unshiftedCodepointForKey(Key key) => _keyToCodepoint[key] ?? 0; + +/// Returns the US-layout codepoint produced by a programmatic key press. +int codepointForKey(Key key, Mods mods) { + final codepoint = unshiftedCodepointForKey(key); + if (codepoint >= 0x61 && codepoint <= 0x7A) { + return mods.hasShift != mods.hasCapsLock ? codepoint - 0x20 : codepoint; + } + return mods.hasShift ? _shiftedCodepoints[codepoint] ?? codepoint : codepoint; +} + +/// Returns modifiers consumed by the US-layout programmatic translation. +Mods consumedModsForKey(Key key, Mods mods) { + final unshifted = unshiftedCodepointForKey(key); + var consumed = const Mods.none(); + if (unshifted >= 0x61 && unshifted <= 0x7A) { + if (mods.hasShift) consumed = consumed | const Mods.shift(); + if (mods.hasCapsLock) consumed = consumed | const Mods.capsLock(); + } else if (mods.hasShift && codepointForKey(key, mods) != unshifted) { + consumed = consumed | const Mods.shift(); + } + return consumed; +} diff --git a/packages/flterm/lib/src/foundation/terminal_config.dart b/packages/flterm/lib/src/foundation/terminal_config.dart index d3cf8dec..525b7125 100644 --- a/packages/flterm/lib/src/foundation/terminal_config.dart +++ b/packages/flterm/lib/src/foundation/terminal_config.dart @@ -124,6 +124,13 @@ class TerminalConfig { /// addition to Kitty graphics. final bool glyphProtocol; + /// Whether macOS Option keys act as terminal Alt modifiers. + /// + /// By default Option participates in the active keyboard layout. Set a side + /// or both sides to Alt when terminal shortcuts should take precedence over + /// Option-produced text. + final OptionAsAlt optionAsAlt; + /// Initial cursor shape. Terminal programs can override via DECSCUSR. final CursorShape cursorStyle; @@ -169,6 +176,7 @@ class TerminalConfig { this.rows = 24, this.cursorBlink, this.glyphProtocol = false, + this.optionAsAlt = .false$, this.apcBufferLimit = defaultApcBufferLimit, this.enquiryResponse = '', this.modes = defaultModes, @@ -204,6 +212,7 @@ class TerminalConfig { kittyImageStorageLimit, apcBufferLimit, glyphProtocol, + optionAsAlt, cursorStyle, cursorBlink, .hashAllUnordered(modes.entries.map((e) => .hash(e.key, e.value))), @@ -224,6 +233,7 @@ class TerminalConfig { kittyImageStorageLimit == other.kittyImageStorageLimit && apcBufferLimit == other.apcBufferLimit && glyphProtocol == other.glyphProtocol && + optionAsAlt == other.optionAsAlt && cursorStyle == other.cursorStyle && cursorBlink == other.cursorBlink && _modesEqual(modes, other.modes) && @@ -241,6 +251,7 @@ class TerminalConfig { int? kittyImageStorageLimit, int? apcBufferLimit, bool? glyphProtocol, + OptionAsAlt? optionAsAlt, CursorShape? cursorStyle, bool? cursorBlink, Map? modes, @@ -258,6 +269,7 @@ class TerminalConfig { kittyImageStorageLimit ?? this.kittyImageStorageLimit, apcBufferLimit: apcBufferLimit ?? this.apcBufferLimit, glyphProtocol: glyphProtocol ?? this.glyphProtocol, + optionAsAlt: optionAsAlt ?? this.optionAsAlt, cursorStyle: cursorStyle ?? this.cursorStyle, cursorBlink: cursorBlink ?? this.cursorBlink, modes: modes ?? this.modes, diff --git a/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart b/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart new file mode 100644 index 00000000..37ce161a --- /dev/null +++ b/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart @@ -0,0 +1,88 @@ +import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/services.dart' show KeyEvent; +import 'package:libghostty/libghostty.dart' show Key, KeyAction, Mods; + +/// A keyboard event normalized for terminal protocol encoding. +/// +/// Unlike Flutter's [KeyEvent], this includes the active layout's unshifted +/// codepoint and the modifiers consumed while producing [text]. +@immutable +final class TerminalKeyboardEvent { + /// The physical, layout-independent key. + final Key key; + + /// Whether the key was pressed, repeated, or released. + final KeyAction action; + + /// Modifier and lock state at the time of the event. + final Mods mods; + + /// Modifiers used by the active layout to produce [text]. + final Mods consumedMods; + + /// Text produced by the active keyboard layout, if any. + final String? text; + + /// The active layout's Unicode codepoint for this key without modifiers. + /// + /// Zero means that the platform could not provide a single codepoint. + final int unshiftedCodepoint; + + /// Whether the event belongs to an active dead-key or IME composition. + final bool composing; + + /// Whether platform text input should handle this event instead of the + /// terminal key encoder. + final bool deferToTextInput; + + const TerminalKeyboardEvent({ + required this.key, + required this.action, + required this.mods, + this.consumedMods = const Mods.none(), + this.text, + this.unshiftedCodepoint = 0, + this.composing = false, + this.deferToTextInput = false, + }) : assert( + unshiftedCodepoint >= 0 && + unshiftedCodepoint <= 0x10FFFF && + (unshiftedCodepoint < 0xD800 || unshiftedCodepoint > 0xDFFF), + 'unshiftedCodepoint must be a valid Unicode scalar or zero', + ); + + /// Returns a copy with selected fields replaced. + TerminalKeyboardEvent copyWith({ + Key? key, + KeyAction? action, + Mods? mods, + Mods? consumedMods, + String? text, + bool clearText = false, + int? unshiftedCodepoint, + bool? composing, + bool? deferToTextInput, + }) { + return TerminalKeyboardEvent( + key: key ?? this.key, + action: action ?? this.action, + mods: mods ?? this.mods, + consumedMods: consumedMods ?? this.consumedMods, + text: clearText ? null : text ?? this.text, + unshiftedCodepoint: unshiftedCodepoint ?? this.unshiftedCodepoint, + composing: composing ?? this.composing, + deferToTextInput: deferToTextInput ?? this.deferToTextInput, + ); + } +} + +/// Overrides or enriches flterm's normalized keyboard event. +/// +/// [fallback] contains all information available from Flutter and flterm's +/// native desktop companion. Custom runners can replace fields with metadata +/// captured before Flutter normalizes the platform key event. +typedef TerminalKeyEventNormalizer = + TerminalKeyboardEvent Function( + KeyEvent event, + TerminalKeyboardEvent fallback, + ); diff --git a/packages/flterm/lib/src/input/keyboard_event_normalizer.dart b/packages/flterm/lib/src/input/keyboard_event_normalizer.dart new file mode 100644 index 00000000..1e46679b --- /dev/null +++ b/packages/flterm/lib/src/input/keyboard_event_normalizer.dart @@ -0,0 +1,187 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; +import 'package:flutter/services.dart'; +import 'package:libghostty/libghostty.dart' + show Key, KeyAction, Mods, OptionAsAlt; + +import '../foundation.dart'; +import 'native_keyboard_metadata.dart'; + +final class KeyboardEventNormalizer { + final _unshiftedByPhysicalKey = {}; + final NativeKeyboardMetadata? Function(KeyEvent) _nativeMetadataForEvent; + + KeyboardEventNormalizer({ + NativeKeyboardMetadata? Function(KeyEvent)? nativeMetadataForEvent, + }) : _nativeMetadataForEvent = + nativeMetadataForEvent ?? + NativeKeyboardMetadataStore.instance.forEvent { + if (nativeMetadataForEvent == null) { + NativeKeyboardMetadataStore.instance.ensureInitialized(); + } + } + + TerminalKeyboardEvent normalize( + KeyEvent event, { + required Key key, + required KeyAction action, + required Mods mods, + required String? character, + required bool composing, + required OptionAsAlt optionAsAlt, + }) { + final native = _nativeMetadataForEvent(event); + final effectiveMods = native == null ? mods : native.mods | mods; + var unshiftedCodepoint = native?.unshiftedCodepoint ?? 0; + + if (unshiftedCodepoint == 0) { + unshiftedCodepoint = _fallbackUnshiftedCodepoint( + event, + key: key, + mods: effectiveMods, + character: character, + ); + } else { + _unshiftedByPhysicalKey[event.physicalKey] = unshiftedCodepoint; + } + + var text = character; + final optionActsAsAlt = _optionActsAsAlt(effectiveMods, optionAsAlt); + var consumedMods = native?.consumedMods ?? const Mods.none(); + consumedMods = + consumedMods | + _fallbackConsumedMods( + text, + unshiftedCodepoint: unshiftedCodepoint, + mods: effectiveMods, + ); + if (optionActsAsAlt) { + text = + native?.textWithoutAlt ?? + _textForCodepoint(unshiftedCodepoint) ?? + text; + consumedMods = _withoutAlt(consumedMods); + } + final deadKey = (native?.deadKey ?? false) && !optionActsAsAlt; + + return TerminalKeyboardEvent( + key: key, + action: action, + mods: effectiveMods, + consumedMods: consumedMods, + text: text, + unshiftedCodepoint: unshiftedCodepoint, + composing: composing || deadKey, + deferToTextInput: deadKey, + ); + } + + int _fallbackUnshiftedCodepoint( + KeyEvent event, { + required Key key, + required Mods mods, + required String? character, + }) { + final unmodified = + !mods.hasShift && + !mods.hasCtrl && + !mods.hasAlt && + !mods.hasSuper && + !mods.hasCapsLock; + if (unmodified) { + final codepoint = _singleCodepoint(character); + if (codepoint != 0) { + _unshiftedByPhysicalKey[event.physicalKey] = codepoint; + return codepoint; + } + } + + final cached = _unshiftedByPhysicalKey[event.physicalKey]; + if (cached != null) return cached; + + final logical = _singleCodepoint(event.logicalKey.keyLabel); + if (logical != 0) { + final lowered = _singleCodepoint(event.logicalKey.keyLabel.toLowerCase()); + return lowered == 0 ? logical : lowered; + } + + return unshiftedCodepointForKey(key); + } + + Mods _fallbackConsumedMods( + String? character, { + required int unshiftedCodepoint, + required Mods mods, + }) { + if (character == null || unshiftedCodepoint == 0) { + return const Mods.none(); + } + final codepoint = _singleCodepoint(character); + if (codepoint == 0) return const Mods.none(); + + var consumed = const Mods.none(); + if (mods.hasShift && codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.shift(); + } + if (mods.hasCapsLock && codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.capsLock(); + } + + final keyboard = HardwareKeyboard.instance; + final altGraph = keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.altGraph, + ); + final rightAlt = keyboard.physicalKeysPressed.contains( + PhysicalKeyboardKey.altRight, + ); + if (defaultTargetPlatform != TargetPlatform.macOS && + mods.hasAlt && + (altGraph || (rightAlt && mods.hasCtrl))) { + consumed = consumed | const Mods.alt(); + if (mods.hasCtrl) consumed = consumed | const Mods.ctrl(); + } else if (defaultTargetPlatform == TargetPlatform.macOS && + mods.hasAlt && + !mods.hasCtrl && + !mods.hasSuper && + codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.alt(); + } + return consumed; + } + + bool _optionActsAsAlt(Mods mods, OptionAsAlt option) { + if (defaultTargetPlatform != TargetPlatform.macOS || !mods.hasAlt) { + return false; + } + return switch (option) { + .false$ => false, + .true$ => true, + .left => !mods.isAltRight, + .right => mods.isAltRight, + }; + } + + Mods _withoutAlt(Mods mods) { + if (!mods.hasAlt) return mods; + return mods ^ + const Mods.alt() ^ + (mods.isAltRight ? const Mods.altSide() : const Mods.none()); + } + + int _singleCodepoint(String? value) { + if (value == null || value.isEmpty) return 0; + final runes = value.runes.iterator; + if (!runes.moveNext()) return 0; + final codepoint = runes.current; + return runes.moveNext() ? 0 : codepoint; + } + + String? _textForCodepoint(int codepoint) { + if (codepoint <= 0 || + codepoint > 0x10FFFF || + (codepoint >= 0xD800 && codepoint <= 0xDFFF)) { + return null; + } + return String.fromCharCode(codepoint); + } +} diff --git a/packages/flterm/lib/src/input/keyboard_input_adapter.dart b/packages/flterm/lib/src/input/keyboard_input_adapter.dart index 9a57640d..66ed4ab7 100644 --- a/packages/flterm/lib/src/input/keyboard_input_adapter.dart +++ b/packages/flterm/lib/src/input/keyboard_input_adapter.dart @@ -6,6 +6,7 @@ import 'package:libghostty/libghostty.dart' hide KeyEvent; import '../controller/terminal_controller.dart'; import '../foundation.dart'; import 'input_message.dart'; +import 'keyboard_event_normalizer.dart'; import 'text_input_session.dart'; /// Coordinates hardware-key and platform text-input handling for a @@ -28,12 +29,16 @@ final class KeyboardInputAdapter extends ChangeNotifier { final TerminalControllerImpl _controller; final _textInput = TextInputSession(); + final KeyboardEventNormalizer _normalizer; + final TerminalKeyEventNormalizer? _keyEventNormalizer; + final _textInputPhysicalKeys = {}; FocusNode? _focusNode; Brightness _keyboardAppearance = .dark; var _preeditText = ''; var _wasFocused = false; - KeyboardInputAdapter(this._controller) { + KeyboardInputAdapter(this._controller, {this._keyEventNormalizer}) + : _normalizer = KeyboardEventNormalizer() { _textInput ..onTextCommitted = _controller.handleTextCommitted ..onDelete = _controller.handleTextDeleted @@ -56,6 +61,19 @@ final class KeyboardInputAdapter extends ChangeNotifier { if (keyboard.isControlPressed) mods |= const Mods.ctrl(); if (keyboard.isAltPressed) mods |= const Mods.alt(); if (keyboard.isMetaPressed) mods |= const Mods.superKey(); + final pressed = keyboard.physicalKeysPressed; + if (pressed.contains(PhysicalKeyboardKey.shiftRight)) { + mods |= const Mods.shiftSide(); + } + if (pressed.contains(PhysicalKeyboardKey.controlRight)) { + mods |= const Mods.ctrlSide(); + } + if (pressed.contains(PhysicalKeyboardKey.altRight)) { + mods |= const Mods.altSide(); + } + if (pressed.contains(PhysicalKeyboardKey.metaRight)) { + mods |= const Mods.superSide(); + } final lockModes = keyboard.lockModesEnabled; if (lockModes.contains(KeyboardLockMode.capsLock)) { mods |= const Mods.capsLock(); @@ -120,28 +138,47 @@ final class KeyboardInputAdapter extends ChangeNotifier { 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 character = _encoderCharacter(event.character); final composing = _textInput.hasActiveComposition || _preeditText.isNotEmpty; - final input = KeyInput( + var normalized = _normalizer.normalize( + event, key: key, action: action, - mods: terminalMods, + mods: mods, character: character, composing: composing, + optionAsAlt: _controller.config.optionAsAlt, + ); + normalized = _keyEventNormalizer?.call(event, normalized) ?? normalized; + + if (action == .release && + _textInputPhysicalKeys.remove(event.physicalKey)) { + return .skipRemainingHandlers; + } + if (normalized.deferToTextInput) { + if (action == .press || action == .repeat) { + _textInputPhysicalKeys.add(event.physicalKey); + } + return .skipRemainingHandlers; + } + if (action == .press) _textInputPhysicalKeys.remove(event.physicalKey); + + final virtualMods = _controller.virtualMods; + final consumedMods = + normalized.consumedMods ^ (normalized.consumedMods & virtualMods); + final terminalMods = consumedMods.hasCtrl + ? normalized.mods ^ const Mods.ctrl() + : normalized.mods; + final input = KeyInput( + key: normalized.key, + action: normalized.action, + mods: terminalMods, + character: normalized.text, + composing: normalized.composing, consumedMods: consumedMods, - unshiftedCodepoint: unshiftedCodepoint, + unshiftedCodepoint: normalized.unshiftedCodepoint, ); if (_shouldForwardCompositionKey(input)) return .skipRemainingHandlers; @@ -228,34 +265,6 @@ final class KeyboardInputAdapter extends ChangeNotifier { !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); diff --git a/packages/flterm/lib/src/input/native_keyboard_metadata.dart b/packages/flterm/lib/src/input/native_keyboard_metadata.dart new file mode 100644 index 00000000..4dab3766 --- /dev/null +++ b/packages/flterm/lib/src/input/native_keyboard_metadata.dart @@ -0,0 +1,223 @@ +// Flutter's public KeyEvent omits native layout metadata that terminal +// protocols need. Desktop plugins send a companion record before Flutter's +// own key messages; the deprecated raw event is used only to correlate that +// record with the KeyEvent object delivered to Focus. +// ignore_for_file: deprecated_member_use + +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:libghostty/libghostty.dart' show Mods; + +final class NativeKeyboardMetadata { + final Mods mods; + final Mods consumedMods; + final int unshiftedCodepoint; + final String? textWithoutAlt; + final bool deadKey; + + const NativeKeyboardMetadata({ + required this.mods, + required this.consumedMods, + required this.unshiftedCodepoint, + required this.textWithoutAlt, + required this.deadKey, + }); +} + +final class NativeKeyboardMetadataStore { + static final instance = NativeKeyboardMetadataStore._(); + static const _channelName = 'dev.flterm/native_keyboard'; + static const _queueLimit = 64; + + final _nativeEvents = ListQueue<_NativeKeyboardRecord>(); + final _pairedEvents = ListQueue<_PairedKeyboardRecord>(); + final _metadata = Expando(); + var _initialized = false; + + NativeKeyboardMetadataStore._(); + + void ensureInitialized() { + if (_initialized || kIsWeb || !_isDesktopPlatform) return; + _initialized = true; + const BasicMessageChannel( + _channelName, + StandardMessageCodec(), + ).setMessageHandler(_handleMessage); + RawKeyboard.instance.addListener(_handleRawEvent); + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + NativeKeyboardMetadata? forEvent(KeyEvent event) => _metadata[event]; + + bool get _isDesktopPlatform => switch (defaultTargetPlatform) { + .linux || .macOS || .windows => true, + .android || .fuchsia || .iOS => false, + }; + + bool _handleKeyEvent(KeyEvent event) { + if (event.synthesized) return false; + final down = event is! KeyUpEvent; + final records = _pairedEvents.toList(); + final index = records.lastIndexWhere( + (record) => + record.down == down && record.physicalKey == event.physicalKey, + ); + if (index < 0) return false; + final record = records[index]; + _pairedEvents.removeWhere( + (candidate) => + candidate.down == down && candidate.physicalKey == event.physicalKey, + ); + _metadata[event] = record.metadata; + return false; + } + + Future _handleMessage(Object? message) async { + final record = _NativeKeyboardRecord.fromMessage(message); + if (record == null) return null; + if (record.eventTime != 0 && _nativeEvents.any(record.sameNativeEvent)) { + return null; + } + _nativeEvents.addLast(record); + while (_nativeEvents.length > _queueLimit) { + _nativeEvents.removeFirst(); + } + return null; + } + + void _handleRawEvent(RawKeyEvent event) { + final down = event is RawKeyDownEvent; + final records = _nativeEvents.toList(); + final index = records.lastIndexWhere( + (record) => record.down == down && record.matches(event.data), + ); + if (index < 0) return; + final record = records[index]; + _nativeEvents.removeWhere( + (candidate) => candidate.down == down && candidate.matches(event.data), + ); + _pairedEvents.addLast( + _PairedKeyboardRecord( + physicalKey: event.physicalKey, + down: down, + metadata: record.metadata, + ), + ); + while (_pairedEvents.length > _queueLimit) { + _pairedEvents.removeFirst(); + } + } +} + +final class _NativeKeyboardRecord { + final String platform; + final int scanCode; + final int keyCode; + final bool down; + final int eventTime; + final NativeKeyboardMetadata metadata; + + const _NativeKeyboardRecord({ + required this.platform, + required this.scanCode, + required this.keyCode, + required this.down, + required this.eventTime, + required this.metadata, + }); + + static _NativeKeyboardRecord? fromMessage(Object? message) { + if (message is! Map) return null; + final platform = message['platform']; + final scanCode = message['scanCode']; + final keyCode = message['keyCode']; + final down = message['down']; + final eventTime = message['eventTime']; + final mods = message['mods']; + final consumedMods = message['consumedMods']; + final unshiftedCodepoint = message['unshiftedCodepoint']; + final textWithoutAlt = message['textWithoutAlt']; + final deadKey = message['deadKey']; + if (platform is! String || + !const {'linux', 'macos', 'windows'}.contains(platform) || + scanCode is! int || + scanCode < 0 || + keyCode is! int || + keyCode < 0 || + down is! bool || + eventTime is! int || + eventTime < 0 || + mods is! int || + consumedMods is! int || + unshiftedCodepoint is! int || + unshiftedCodepoint < 0 || + unshiftedCodepoint > 0x10FFFF || + (unshiftedCodepoint >= 0xD800 && unshiftedCodepoint <= 0xDFFF) || + (textWithoutAlt != null && textWithoutAlt is! String) || + deadKey is! bool) { + return null; + } + return _NativeKeyboardRecord( + platform: platform, + scanCode: scanCode, + keyCode: keyCode, + down: down, + eventTime: eventTime, + metadata: NativeKeyboardMetadata( + mods: _modsFromBits(mods), + consumedMods: _modsFromBits(consumedMods), + unshiftedCodepoint: unshiftedCodepoint, + textWithoutAlt: textWithoutAlt as String?, + deadKey: deadKey, + ), + ); + } + + bool matches(RawKeyEventData data) { + return switch ((platform, data)) { + ('linux', final RawKeyEventDataLinux event) => + event.scanCode == scanCode && event.keyCode == keyCode, + ('macos', final RawKeyEventDataMacOs event) => event.keyCode == keyCode, + ('windows', final RawKeyEventDataWindows event) => + event.scanCode == scanCode && event.keyCode == keyCode, + _ => false, + }; + } + + bool sameNativeEvent(_NativeKeyboardRecord other) { + return platform == other.platform && + scanCode == other.scanCode && + keyCode == other.keyCode && + down == other.down && + eventTime == other.eventTime; + } +} + +final class _PairedKeyboardRecord { + final PhysicalKeyboardKey physicalKey; + final bool down; + final NativeKeyboardMetadata metadata; + + const _PairedKeyboardRecord({ + required this.physicalKey, + required this.down, + required this.metadata, + }); +} + +Mods _modsFromBits(int bits) { + var mods = const Mods.none(); + if (bits & (1 << 0) != 0) mods = mods | const Mods.shift(); + if (bits & (1 << 1) != 0) mods = mods | const Mods.ctrl(); + if (bits & (1 << 2) != 0) mods = mods | const Mods.alt(); + if (bits & (1 << 3) != 0) mods = mods | const Mods.superKey(); + if (bits & (1 << 4) != 0) mods = mods | const Mods.capsLock(); + if (bits & (1 << 5) != 0) mods = mods | const Mods.numLock(); + if (bits & (1 << 6) != 0) mods = mods | const Mods.shiftSide(); + if (bits & (1 << 7) != 0) mods = mods | const Mods.ctrlSide(); + if (bits & (1 << 8) != 0) mods = mods | const Mods.altSide(); + if (bits & (1 << 9) != 0) mods = mods | const Mods.superSide(); + return mods; +} diff --git a/packages/flterm/lib/src/view/view_attachment.dart b/packages/flterm/lib/src/view/view_attachment.dart index 1466b3f6..1fa98758 100644 --- a/packages/flterm/lib/src/view/view_attachment.dart +++ b/packages/flterm/lib/src/view/view_attachment.dart @@ -70,7 +70,10 @@ final class ViewAttachment extends ChangeNotifier { ViewAttachment._(this._controller) : _viewToken = _controller.attachView(), - input = KeyboardInputAdapter(_controller) { + input = KeyboardInputAdapter( + _controller, + keyEventNormalizer: _controller.keyEventNormalizer, + ) { frameSource = FrameSource( terminal, viewportChanges: _controller.viewportChanges, diff --git a/packages/flterm/linux/CMakeLists.txt b/packages/flterm/linux/CMakeLists.txt new file mode 100644 index 00000000..17d3330b --- /dev/null +++ b/packages/flterm/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.10) + +set(PROJECT_NAME "flterm") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "flterm_plugin") + +add_library(${PLUGIN_NAME} SHARED + "flterm_plugin.cc" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter PkgConfig::GTK) + +set(flterm_bundled_libraries "" PARENT_SCOPE) diff --git a/packages/flterm/linux/flterm_plugin.cc b/packages/flterm/linux/flterm_plugin.cc new file mode 100644 index 00000000..1684e1f4 --- /dev/null +++ b/packages/flterm/linux/flterm_plugin.cc @@ -0,0 +1,294 @@ +#include "include/flterm/flterm_plugin.h" + +#include +#include +#include + +#define FLTERM_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), flterm_plugin_get_type(), FltermPlugin)) + +namespace { + +constexpr char kChannelName[] = "dev.flterm/native_keyboard"; + +enum ModBits { + kShift = 1 << 0, + kControl = 1 << 1, + kAlt = 1 << 2, + kSuper = 1 << 3, + kCapsLock = 1 << 4, + kNumLock = 1 << 5, + kShiftSide = 1 << 6, + kControlSide = 1 << 7, + kAltSide = 1 << 8, + kSuperSide = 1 << 9, +}; + +int64_t ModifiersFromGdk(guint state, bool level3_is_alt = false) { +#if defined(FLUTTER_LINUX_GTK4) + (void)level3_is_alt; +#endif + int64_t result = 0; + if ((state & GDK_SHIFT_MASK) != 0) result |= kShift; + if ((state & GDK_CONTROL_MASK) != 0) result |= kControl; +#if defined(FLUTTER_LINUX_GTK4) + if ((state & GDK_ALT_MASK) != 0) result |= kAlt; +#else + if ((state & GDK_MOD1_MASK) != 0) result |= kAlt; + if (level3_is_alt && (state & GDK_MOD5_MASK) != 0) result |= kAlt; +#endif + if ((state & (GDK_SUPER_MASK | GDK_META_MASK)) != 0) result |= kSuper; + if ((state & GDK_LOCK_MASK) != 0) result |= kCapsLock; +#if !defined(FLUTTER_LINUX_GTK4) + if ((state & GDK_MOD2_MASK) != 0) result |= kNumLock; +#endif + return result; +} + +bool IsModifierKey(guint keyval) { + switch (keyval) { + case GDK_KEY_Shift_L: + case GDK_KEY_Shift_R: + case GDK_KEY_Control_L: + case GDK_KEY_Control_R: + case GDK_KEY_Alt_L: + case GDK_KEY_Alt_R: + case GDK_KEY_Meta_L: + case GDK_KEY_Meta_R: + case GDK_KEY_Super_L: + case GDK_KEY_Super_R: + case GDK_KEY_Hyper_L: + case GDK_KEY_Hyper_R: + case GDK_KEY_Caps_Lock: + case GDK_KEY_Num_Lock: + case GDK_KEY_ISO_Level3_Shift: + case GDK_KEY_Mode_switch: + return true; + default: + return false; + } +} + +bool IsDeadKey(guint keyval) { + const gchar* name = gdk_keyval_name(keyval); + return name != nullptr && g_str_has_prefix(name, "dead_"); +} + +void Set(FlValue* map, const char* key, FlValue* value) { + fl_value_set_string_take(map, key, value); +} + +} // namespace + +struct _FltermPlugin { + GObject parent_instance; + FlBasicMessageChannel* channel; + FlView* view; +#if defined(FLUTTER_LINUX_GTK4) + GtkEventController* key_controller; +#endif + gulong key_press_handler; + gulong key_release_handler; +}; + +G_DEFINE_TYPE(FltermPlugin, flterm_plugin, g_object_get_type()) + +static gboolean SendKeyEvent(FltermPlugin* self, + guint keycode, + guint keyval, + guint32 time, + GdkModifierType state, + GdkModifierType consumed, + guint unshifted_keyval, + gboolean down) { + if (IsModifierKey(keyval)) return FALSE; + + gunichar unshifted = gdk_keyval_to_unicode(unshifted_keyval); + if (unshifted < 0x20 || unshifted == 0x7F) unshifted = 0; + + g_autoptr(FlValue) message = fl_value_new_map(); + Set(message, "platform", fl_value_new_string("linux")); + Set(message, "scanCode", fl_value_new_int(keycode)); + Set(message, "keyCode", fl_value_new_int(keyval)); + Set(message, "down", fl_value_new_bool(down)); + Set(message, "eventTime", fl_value_new_int(time)); + Set(message, "mods", fl_value_new_int(ModifiersFromGdk(state, true))); + Set(message, "consumedMods", + fl_value_new_int(ModifiersFromGdk(consumed & state, true))); + Set(message, "unshiftedCodepoint", fl_value_new_int(unshifted)); + Set(message, "textWithoutAlt", fl_value_new_null()); + Set(message, "deadKey", fl_value_new_bool(down && IsDeadKey(keyval))); + fl_basic_message_channel_send(self->channel, message, nullptr, nullptr, + nullptr); + return FALSE; +} + +#if defined(FLUTTER_LINUX_GTK4) +static gboolean SendGtk4KeyEvent(FltermPlugin* self, + GtkEventController* controller, + guint keyval, + guint keycode, + GdkModifierType state, + gboolean down) { + GdkEvent* event = gtk_event_controller_get_current_event(controller); + const guint group = event == nullptr ? 0 : gdk_key_event_get_layout(event); + const GdkModifierType consumed = + event == nullptr ? static_cast(0) + : gdk_key_event_get_consumed_modifiers(event); + guint unshifted_keyval = 0; + gdk_display_translate_key( + gtk_widget_get_display(GTK_WIDGET(self->view)), keycode, + static_cast(0), group, &unshifted_keyval, nullptr, + nullptr, nullptr); + return SendKeyEvent( + self, keycode, keyval, + gtk_event_controller_get_current_event_time(controller), state, consumed, + unshifted_keyval, down); +} + +static gboolean KeyPressCallback(GtkEventControllerKey* controller, + guint keyval, + guint keycode, + GdkModifierType state, + gpointer user_data) { + return SendGtk4KeyEvent(FLTERM_PLUGIN(user_data), + GTK_EVENT_CONTROLLER(controller), keyval, keycode, + state, TRUE); +} + +static void KeyReleaseCallback(GtkEventControllerKey* controller, + guint keyval, + guint keycode, + GdkModifierType state, + gpointer user_data) { + SendGtk4KeyEvent(FLTERM_PLUGIN(user_data), + GTK_EVENT_CONTROLLER(controller), keyval, keycode, state, + FALSE); +} +#else +static gboolean SendGtk3KeyEvent(FltermPlugin* self, + GdkEventKey* event, + gboolean down) { + GdkKeymap* keymap = gdk_keymap_get_for_display( + gtk_widget_get_display(GTK_WIDGET(self->view))); + guint translated_keyval = 0; + gint effective_group = 0; + gint level = 0; + GdkModifierType consumed = static_cast(0); + gdk_keymap_translate_keyboard_state( + keymap, event->hardware_keycode, + static_cast(event->state), event->group, + &translated_keyval, &effective_group, &level, &consumed); + const GdkKeymapKey unshifted_key = { + event->hardware_keycode, + static_cast(event->group), + 0, + }; + const guint unshifted_keyval = gdk_keymap_lookup_key(keymap, &unshifted_key); + return SendKeyEvent(self, event->hardware_keycode, event->keyval, event->time, + static_cast(event->state), consumed, + unshifted_keyval, down); +} + +static gboolean KeyPressCallback(GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + return SendGtk3KeyEvent(FLTERM_PLUGIN(user_data), event, TRUE); +} + +static gboolean KeyReleaseCallback(GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + return SendGtk3KeyEvent(FLTERM_PLUGIN(user_data), event, FALSE); +} +#endif + +static void flterm_plugin_dispose(GObject* object) { + FltermPlugin* self = FLTERM_PLUGIN(object); +#if defined(FLUTTER_LINUX_GTK4) + if (self->key_controller != nullptr) { + if (self->key_press_handler != 0) { + g_signal_handler_disconnect(self->key_controller, + self->key_press_handler); + } + if (self->key_release_handler != 0) { + g_signal_handler_disconnect(self->key_controller, + self->key_release_handler); + } + g_object_remove_weak_pointer( + G_OBJECT(self->key_controller), + reinterpret_cast(&self->key_controller)); + if (self->view != nullptr) { + gtk_widget_remove_controller(GTK_WIDGET(self->view), + self->key_controller); + } + self->key_controller = nullptr; + } +#else + if (self->view != nullptr) { + if (self->key_press_handler != 0) { + g_signal_handler_disconnect(self->view, self->key_press_handler); + } + if (self->key_release_handler != 0) { + g_signal_handler_disconnect(self->view, self->key_release_handler); + } + } +#endif + if (self->view != nullptr) { + g_object_remove_weak_pointer(G_OBJECT(self->view), + reinterpret_cast(&self->view)); + self->view = nullptr; + } + g_clear_object(&self->channel); + G_OBJECT_CLASS(flterm_plugin_parent_class)->dispose(object); +} + +static void flterm_plugin_class_init(FltermPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = flterm_plugin_dispose; +} + +static void flterm_plugin_init(FltermPlugin* self) { + self->channel = nullptr; + self->view = nullptr; +#if defined(FLUTTER_LINUX_GTK4) + self->key_controller = nullptr; +#endif + self->key_press_handler = 0; + self->key_release_handler = 0; +} + +void flterm_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + FltermPlugin* plugin = FLTERM_PLUGIN( + g_object_new(flterm_plugin_get_type(), nullptr)); + g_autoptr(FlStandardMessageCodec) codec = fl_standard_message_codec_new(); + plugin->channel = fl_basic_message_channel_new( + fl_plugin_registrar_get_messenger(registrar), kChannelName, + FL_MESSAGE_CODEC(codec)); + plugin->view = fl_plugin_registrar_get_view(registrar); + if (plugin->view == nullptr) { + g_object_unref(plugin); + return; + } + g_object_add_weak_pointer(G_OBJECT(plugin->view), + reinterpret_cast(&plugin->view)); +#if defined(FLUTTER_LINUX_GTK4) + plugin->key_controller = gtk_event_controller_key_new(); + g_object_add_weak_pointer( + G_OBJECT(plugin->key_controller), + reinterpret_cast(&plugin->key_controller)); + plugin->key_press_handler = + g_signal_connect(plugin->key_controller, "key-pressed", + G_CALLBACK(KeyPressCallback), plugin); + plugin->key_release_handler = + g_signal_connect(plugin->key_controller, "key-released", + G_CALLBACK(KeyReleaseCallback), plugin); + gtk_widget_add_controller(GTK_WIDGET(plugin->view), plugin->key_controller); +#else + plugin->key_press_handler = g_signal_connect( + plugin->view, "key-press-event", G_CALLBACK(KeyPressCallback), plugin); + plugin->key_release_handler = g_signal_connect( + plugin->view, "key-release-event", G_CALLBACK(KeyReleaseCallback), plugin); +#endif + g_object_set_data_full(G_OBJECT(plugin->view), "dev.flterm.plugin", plugin, + g_object_unref); +} diff --git a/packages/flterm/linux/include/flterm/flterm_plugin.h b/packages/flterm/linux/include/flterm/flterm_plugin.h new file mode 100644 index 00000000..af2ad0c7 --- /dev/null +++ b/packages/flterm/linux/include/flterm/flterm_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _FltermPlugin FltermPlugin; +typedef struct { + GObjectClass parent_class; +} FltermPluginClass; + +FLUTTER_PLUGIN_EXPORT GType flterm_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void flterm_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ diff --git a/packages/flterm/macos/flterm.podspec b/packages/flterm/macos/flterm.podspec new file mode 100644 index 00000000..e9ef34b1 --- /dev/null +++ b/packages/flterm/macos/flterm.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'flterm' + s.version = '0.0.4' + s.summary = 'Flutter terminal widget on top of Ghostty.' + s.description = 'Native keyboard metadata companion for flterm.' + s.homepage = 'https://github.com/elias8/libghostty' + s.license = { :file => '../LICENSE' } + s.author = { 'libghostty contributors' => 'opensource@example.invalid' } + s.source = { :path => '.' } + s.source_files = 'flterm/Sources/flterm/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '12.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.9' +end diff --git a/packages/flterm/macos/flterm/Package.swift b/packages/flterm/macos/flterm/Package.swift new file mode 100644 index 00000000..611fe5a2 --- /dev/null +++ b/packages/flterm/macos/flterm/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "flterm", + platforms: [.macOS("12.0")], + products: [.library(name: "flterm", targets: ["flterm"])], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "flterm", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ] + ) + ] +) diff --git a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift new file mode 100644 index 00000000..e185fa0c --- /dev/null +++ b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift @@ -0,0 +1,162 @@ +import Carbon +import Cocoa +import FlutterMacOS + +public final class FltermPlugin: NSObject, FlutterPlugin { + private static let channelName = "dev.flterm/native_keyboard" + + private let channel: FlutterBasicMessageChannel + private weak var view: NSView? + private var monitor: Any? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = FltermPlugin( + messenger: registrar.messenger, + view: registrar.view) + registrar.publish(instance) + } + + private init(messenger: FlutterBinaryMessenger, view: NSView?) { + channel = FlutterBasicMessageChannel( + name: Self.channelName, + binaryMessenger: messenger, + codec: FlutterStandardMessageCodec.sharedInstance()) + self.view = view + super.init() + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .keyUp] + ) { [weak self] event in + self?.handle(event) + return event + } + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + + private func handle(_ event: NSEvent) { + guard let view, + let window = view.window, + event.window === window else { + return + } + + let unmodified = terminalText(event.characters(byApplyingModifiers: [])) + let textWithoutAlt = terminalText( + event.characters( + byApplyingModifiers: event.modifierFlags.subtracting(.option))) + let encodedTextWithoutAlt: Any + if let textWithoutAlt { + encodedTextWithoutAlt = textWithoutAlt + } else { + encodedTextWithoutAlt = NSNull() + } + channel.sendMessage([ + "platform": "macos", + "scanCode": Int(event.keyCode), + "keyCode": Int(event.keyCode), + "down": event.type == .keyDown, + "eventTime": Int((event.timestamp * 1_000_000).rounded()), + "mods": modifierBits(event.modifierFlags), + "consumedMods": consumedModifierBits(event), + "unshiftedCodepoint": singleScalar(unmodified), + "textWithoutAlt": encodedTextWithoutAlt, + "deadKey": event.type == .keyDown && isDeadKey(event), + ]) + } + + private func consumedModifierBits(_ event: NSEvent) -> Int { + guard let produced = terminalText(event.characters) else { return 0 } + var result = 0 + let candidates: [(NSEvent.ModifierFlags, Int)] = [ + (.shift, 1 << 0), + (.option, 1 << 2), + (.capsLock, 1 << 4), + ] + for (flag, bit) in candidates where event.modifierFlags.contains(flag) { + let without = terminalText( + event.characters( + byApplyingModifiers: event.modifierFlags.subtracting(flag))) + if without != produced { + result |= bit + } + } + return result + } + + private func isDeadKey(_ event: NSEvent) -> Bool { + guard let unmanagedSource = TISCopyCurrentKeyboardLayoutInputSource() else { + return (event.characters?.isEmpty ?? true) && + !(event.characters(byApplyingModifiers: [])?.isEmpty ?? true) + } + let source = unmanagedSource.takeRetainedValue() + guard let rawLayout = TISGetInputSourceProperty( + source, + kTISPropertyUnicodeKeyLayoutData) else { + return (event.characters?.isEmpty ?? true) && + !(event.characters(byApplyingModifiers: [])?.isEmpty ?? true) + } + + let layoutData = unsafeBitCast(rawLayout, to: CFData.self) + guard let bytes = CFDataGetBytePtr(layoutData) else { return false } + let layout = UnsafeRawPointer(bytes) + .assumingMemoryBound(to: UCKeyboardLayout.self) + var deadKeyState: UInt32 = 0 + var length = 0 + var characters = [UniChar](repeating: 0, count: 4) + let modifiers = UInt32( + (event.modifierFlags.rawValue >> 16) & 0xFF) + let status = characters.withUnsafeMutableBufferPointer { buffer in + UCKeyTranslate( + layout, + event.keyCode, + UInt16(kUCKeyActionDown), + modifiers, + UInt32(LMGetKbdType()), + OptionBits(0), + &deadKeyState, + buffer.count, + &length, + buffer.baseAddress!) + } + return status == noErr && deadKeyState != 0 + } + + private func modifierBits(_ flags: NSEvent.ModifierFlags) -> Int { + var result = 0 + if flags.contains(.shift) { result |= 1 << 0 } + if flags.contains(.control) { result |= 1 << 1 } + if flags.contains(.option) { result |= 1 << 2 } + if flags.contains(.command) { result |= 1 << 3 } + if flags.contains(.capsLock) { result |= 1 << 4 } + + let raw = flags.rawValue + if raw & 0x04 != 0 { result |= 1 << 6 } + if raw & 0x2000 != 0 { result |= 1 << 7 } + if raw & 0x40 != 0 { result |= 1 << 8 } + if raw & 0x10 != 0 { result |= 1 << 9 } + return result + } + + private func singleScalar(_ value: String?) -> Int { + guard let value else { return 0 } + let scalars = value.unicodeScalars + guard scalars.count == 1, let scalar = scalars.first else { return 0 } + return Int(scalar.value) + } + + private func terminalText(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + let scalars = value.unicodeScalars + if scalars.count == 1, let codepoint = scalars.first?.value { + if codepoint < 0x20 || codepoint == 0x7F || + (codepoint >= 0xF700 && codepoint <= 0xF8FF) { + return nil + } + } + return value + } +} diff --git a/packages/flterm/pubspec.yaml b/packages/flterm/pubspec.yaml index b5f4886f..9418ef32 100644 --- a/packages/flterm/pubspec.yaml +++ b/packages/flterm/pubspec.yaml @@ -33,6 +33,16 @@ dependencies: libghostty: ^0.0.12 meta: ^1.18.0 +flutter: + plugin: + platforms: + linux: + pluginClass: FltermPlugin + macos: + pluginClass: FltermPlugin + windows: + pluginClass: FltermPluginCApi + dev_dependencies: crypto: ^3.0.7 fake_async: ^1.3.3 diff --git a/packages/flterm/test/foundation/platform_map_test.dart b/packages/flterm/test/foundation/platform_map_test.dart index 4d0e1342..4bdc1632 100644 --- a/packages/flterm/test/foundation/platform_map_test.dart +++ b/packages/flterm/test/foundation/platform_map_test.dart @@ -1,7 +1,7 @@ import 'package:flterm/src/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' show Key; +import 'package:libghostty/libghostty.dart' show Key, Mods; void main() { group('PlatformMap', () { @@ -78,6 +78,7 @@ void main() { expect(unshiftedCodepointForKey(Key.bracketLeft), 0x5b); expect(unshiftedCodepointForKey(Key.backslash), 0x5c); expect(unshiftedCodepointForKey(Key.slash), 0x2f); + expect(unshiftedCodepointForKey(Key.space), 0x20); }); test('returns zero for non-character keys', () { @@ -86,5 +87,34 @@ void main() { expect(unshiftedCodepointForKey(Key.f1), 0); }); }); + + group('programmatic key translation', () { + test('applies US shift and caps lock translation', () { + expect(codepointForKey(Key.a, const Mods.shift()), 0x41); + expect(codepointForKey(Key.a, const Mods.capsLock()), 0x41); + expect( + codepointForKey(Key.a, const Mods.shift() | const Mods.capsLock()), + 0x61, + ); + expect(codepointForKey(Key.digit1, const Mods.shift()), 0x21); + expect(codepointForKey(Key.space, const Mods.none()), 0x20); + }); + + test('reports modifiers consumed by translation', () { + expect(consumedModsForKey(Key.a, const Mods.shift()).hasShift, isTrue); + expect( + consumedModsForKey(Key.a, const Mods.capsLock()).hasCapsLock, + isTrue, + ); + expect( + consumedModsForKey(Key.digit1, const Mods.shift()).hasShift, + isTrue, + ); + expect( + consumedModsForKey(Key.space, const Mods.shift()).isEmpty, + isTrue, + ); + }); + }); }); } diff --git a/packages/flterm/test/foundation/terminal_config_test.dart b/packages/flterm/test/foundation/terminal_config_test.dart index 38ed9873..f41706c8 100644 --- a/packages/flterm/test/foundation/terminal_config_test.dart +++ b/packages/flterm/test/foundation/terminal_config_test.dart @@ -15,6 +15,7 @@ void main() { expect(config.cursorStyle, CursorShape.block); expect(config.cursorBlink, isNull); expect(config.glyphProtocol, isFalse); + expect(config.optionAsAlt, OptionAsAlt.false$); expect(config.apcBufferLimit, TerminalConfig.defaultApcBufferLimit); expect(config.scrollToBottom, ScrollToBottom.onKeystroke); expect(config.selectionClearOnTyping, isTrue); @@ -70,6 +71,7 @@ void main() { scrollbackMaxLines: 999, apcBufferLimit: 1024, glyphProtocol: true, + optionAsAlt: OptionAsAlt.right, cursorBlink: false, ); @@ -77,6 +79,7 @@ void main() { expect(updated.scrollbackMaxLines, 999); expect(updated.apcBufferLimit, 1024); expect(updated.glyphProtocol, isTrue); + expect(updated.optionAsAlt, OptionAsAlt.right); expect(updated.cursorBlink, isFalse); expect(updated.cols, config.cols); }); diff --git a/packages/flterm/test/foundation/terminal_keyboard_event_test.dart b/packages/flterm/test/foundation/terminal_keyboard_event_test.dart new file mode 100644 index 00000000..85cf8e3c --- /dev/null +++ b/packages/flterm/test/foundation/terminal_keyboard_event_test.dart @@ -0,0 +1,52 @@ +import 'package:flterm/src/foundation/terminal_keyboard_event.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart'; + +void main() { + group('TerminalKeyboardEvent', () { + test('stores normalized terminal input', () { + const event = TerminalKeyboardEvent( + key: Key.a, + action: KeyAction.press, + mods: Mods.shift(), + consumedMods: Mods.shift(), + text: 'A', + unshiftedCodepoint: 0x61, + composing: true, + deferToTextInput: true, + ); + + expect(event.key, Key.a); + expect(event.action, KeyAction.press); + expect(event.mods.hasShift, isTrue); + expect(event.consumedMods.hasShift, isTrue); + expect(event.text, 'A'); + expect(event.unshiftedCodepoint, 0x61); + expect(event.composing, isTrue); + expect(event.deferToTextInput, isTrue); + }); + + test('copyWith replaces and clears fields', () { + const event = TerminalKeyboardEvent( + key: Key.a, + action: KeyAction.press, + mods: Mods.none(), + text: 'a', + unshiftedCodepoint: 0x61, + ); + + final copy = event.copyWith( + key: Key.b, + action: KeyAction.repeat, + clearText: true, + unshiftedCodepoint: 0x62, + ); + + expect(copy.key, Key.b); + expect(copy.action, KeyAction.repeat); + expect(copy.text, isNull); + expect(copy.unshiftedCodepoint, 0x62); + expect(copy.mods.isEmpty, isTrue); + }); + }); +} diff --git a/packages/flterm/test/input/keyboard_event_normalizer_test.dart b/packages/flterm/test/input/keyboard_event_normalizer_test.dart new file mode 100644 index 00000000..a434dfae --- /dev/null +++ b/packages/flterm/test/input/keyboard_event_normalizer_test.dart @@ -0,0 +1,140 @@ +import 'package:flterm/src/input/keyboard_event_normalizer.dart'; +import 'package:flterm/src/input/native_keyboard_metadata.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' hide KeyEvent; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const keyEvent = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + character: 'A', + timeStamp: Duration.zero, + ); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('uses native layout translation and consumed modifiers', () { + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.shift(), + consumedMods: Mods.shift(), + unshiftedCodepoint: 0x71, + textWithoutAlt: null, + deadKey: false, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.shift(), + character: 'A', + composing: false, + optionAsAlt: OptionAsAlt.false$, + ); + + expect(result.unshiftedCodepoint, 0x71); + expect(result.consumedMods.hasShift, isTrue); + expect(result.text, 'A'); + expect(result.deferToTextInput, isFalse); + }); + + test('option as alt preserves other translation modifiers', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.none(), + consumedMods: Mods.none(), + unshiftedCodepoint: 0x32, + textWithoutAlt: '@', + deadKey: true, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.digit2, + action: KeyAction.press, + mods: const Mods.shift() | const Mods.alt(), + character: '€', + composing: false, + optionAsAlt: OptionAsAlt.true$, + ); + + expect(result.text, '@'); + expect(result.mods.hasAlt, isTrue); + expect(result.consumedMods.hasAlt, isFalse); + expect(result.consumedMods.hasShift, isTrue); + expect(result.composing, isFalse); + expect(result.deferToTextInput, isFalse); + }); + + test('dead key defers to platform text input', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.alt(), + consumedMods: Mods.alt(), + unshiftedCodepoint: 0x65, + textWithoutAlt: 'e', + deadKey: true, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.e, + action: KeyAction.press, + mods: const Mods.alt(), + character: null, + composing: false, + optionAsAlt: OptionAsAlt.false$, + ); + + expect(result.composing, isTrue); + expect(result.deferToTextInput, isTrue); + }); + + test('option as alt honors the configured side', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.none(), + consumedMods: Mods.none(), + unshiftedCodepoint: 0x61, + textWithoutAlt: 'a', + deadKey: false, + ), + ); + + final left = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.alt(), + character: 'å', + composing: false, + optionAsAlt: OptionAsAlt.right, + ); + final right = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.alt() | const Mods.altSide(), + character: 'å', + composing: false, + optionAsAlt: OptionAsAlt.right, + ); + + expect(left.text, 'å'); + expect(right.text, 'a'); + }); +} diff --git a/packages/flterm/test/input/native_keyboard_metadata_test.dart b/packages/flterm/test/input/native_keyboard_metadata_test.dart new file mode 100644 index 00000000..8e04272c --- /dev/null +++ b/packages/flterm/test/input/native_keyboard_metadata_test.dart @@ -0,0 +1,117 @@ +import 'package:flterm/src/input/native_keyboard_metadata.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('newest native record wins after an unmatched redispatch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final store = NativeKeyboardMetadataStore.instance..ensureInitialized(); + final received = []; + bool capture(KeyEvent event) { + if (event.physicalKey == PhysicalKeyboardKey.keyA) { + final metadata = store.forEvent(event); + if (metadata != null) received.add(metadata); + } + return false; + } + + HardwareKeyboard.instance.addHandler(capture); + addTearDown(() => HardwareKeyboard.instance.removeHandler(capture)); + + final keyData = KeyEventSimulator.getKeyData( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + + Future sendMetadata({ + required int eventTime, + required int unshiftedCodepoint, + bool down = true, + }) async { + final message = { + 'platform': 'linux', + 'scanCode': keyData['scanCode']! as int, + 'keyCode': keyData['keyCode']! as int, + 'down': down, + 'eventTime': eventTime, + 'mods': 0, + 'consumedMods': 0, + 'unshiftedCodepoint': unshiftedCodepoint, + 'textWithoutAlt': null, + 'deadKey': false, + }; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'dev.flterm/native_keyboard', + const StandardMessageCodec().encodeMessage(message), + (_) {}, + ); + } + + await sendMetadata(eventTime: 1, unshiftedCodepoint: 0x71); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + // A native redispatch can arrive without a second Flutter key event. + await sendMetadata(eventTime: 1, unshiftedCodepoint: 0x71); + await sendMetadata(eventTime: 2, unshiftedCodepoint: 0x7A); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + // A raw key-up without a regularized KeyEvent can also leave a stale + // second-stage record. The next complete tap must use newer metadata. + await sendMetadata(eventTime: 3, unshiftedCodepoint: 0x78, down: false); + final orphanUp = {...keyData, 'type': 'keyup'}; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.keyEvent.name, + SystemChannels.keyEvent.codec.encodeMessage(orphanUp), + (_) {}, + ); + await sendMetadata(eventTime: 4, unshiftedCodepoint: 0x62); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await sendMetadata(eventTime: 5, unshiftedCodepoint: 0x63, down: false); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + debugDefaultTargetPlatformOverride = null; + expect(received, hasLength(4)); + expect(received.first.unshiftedCodepoint, 0x71); + expect(received[1].unshiftedCodepoint, 0x7A); + expect(received[2].unshiftedCodepoint, 0x62); + expect(received.last.unshiftedCodepoint, 0x63); + }); +} diff --git a/packages/flterm/test/rendering/frame_builder_test.dart b/packages/flterm/test/rendering/frame_builder_test.dart index 14c74b1c..994f2beb 100644 --- a/packages/flterm/test/rendering/frame_builder_test.dart +++ b/packages/flterm/test/rendering/frame_builder_test.dart @@ -146,6 +146,18 @@ void main() { expect(atlas.emojiImage, isNotNull); }); + test('semantics iteration preserves subsequent terminal updates', () { + writeUtf8(terminal, 'visible'); + builder.sync(terminal, terminalDirty: true); + + expect(builder.semanticsText(), contains('visible')); + + writeUtf8(terminal, ' updated'); + builder.sync(terminal, terminalDirty: true); + + expect(builder.semanticsText(), contains('updated')); + }); + test('sync lets a private-use symbol occupy a following blank cell', () { writeUtf8(terminal, '\u{E5FF} A'); diff --git a/packages/flterm/test/view/terminal_view_test.dart b/packages/flterm/test/view/terminal_view_test.dart index 76f24567..9764bd7b 100644 --- a/packages/flterm/test/view/terminal_view_test.dart +++ b/packages/flterm/test/view/terminal_view_test.dart @@ -14,6 +14,7 @@ import 'package:flutter/foundation.dart' debugDefaultTargetPlatformOverride, defaultTargetPlatform; import 'package:flutter/gestures.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' @@ -163,6 +164,8 @@ void main() { MouseAutoHide mouseAutoHide = .onInput, TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), LinkSettings linkSettings = const LinkSettings(), + String? semanticsLabel = 'Terminal', + String? semanticsHint = 'Activate to focus terminal input', EdgeInsets padding = EdgeInsets.zero, double width = 800, double height = 480, @@ -183,6 +186,8 @@ void main() { mouseAutoHide: mouseAutoHide, gestureSettings: gestureSettings, linkSettings: linkSettings, + semanticsLabel: semanticsLabel, + semanticsHint: semanticsHint, padding: padding, fontData: fontData, ), @@ -274,6 +279,53 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('semantics expose visible non-concealed terminal text', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + wrapInApp( + controller: controller, + semanticsLabel: 'Remote shell', + semanticsHint: 'Focus remote shell input', + ), + ); + await tester.pumpAndSettle(); + writeUtf8(controller, 'visible\r\nshow \x1b[8msecret\x1b[0m text'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + final node = tester.getSemantics(find.bySemanticsLabel('Remote shell')); + final data = node.getSemanticsData(); + expect(data.label, 'Remote shell'); + expect(data.value, contains('visible')); + expect(data.value, contains('show')); + expect(data.value, contains('text')); + expect(data.value, isNot(contains('secret'))); + expect(data.hint, 'Focus remote shell input'); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.hasAction(SemanticsAction.focus), isTrue); + expect(data.flagsCollection.isLiveRegion, isFalse); + } finally { + semantics.dispose(); + } + }); + + testWidgets('semantics can be delegated to an embedding application', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + wrapInApp(controller: controller, semanticsLabel: null), + ); + expect(find.bySemanticsLabel('Terminal'), findsNothing); + } finally { + semantics.dispose(); + } + }); + testWidgets('creates an isolated atlas pool without explicit scope', ( tester, ) async { @@ -509,6 +561,37 @@ void main() { expect(renderer(tester).blinkVisible, isFalse); }); + testWidgets('semantics follow the visible scrollback viewport', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + final scrollController = await pumpBlinkingScrollableTerminal(tester); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + String semanticsValue() => tester + .getSemantics(find.bySemanticsLabel('Terminal')) + .getSemanticsData() + .value; + + expect(semanticsValue(), contains('line 39')); + + scrollController.jumpTo(0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(semanticsValue(), contains('line 0')); + expect(semanticsValue(), isNot(contains('line 39'))); + + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(semanticsValue(), contains('line 39')); + } finally { + semantics.dispose(); + } + }); + testWidgets('focus loss stops cursor blinking in the visible phase', ( tester, ) async { @@ -1290,6 +1373,36 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('changing controller clears cached terminal semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final controller2 = TerminalController(); + addTearDown(controller2.dispose); + try { + writeUtf8(controller, 'old terminal'); + await tester.pumpWidget(wrapInApp(controller: controller)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + String semanticsValue() => tester + .getSemantics(find.bySemanticsLabel('Terminal')) + .getSemanticsData() + .value; + expect(semanticsValue(), contains('old terminal')); + + writeUtf8(controller2, 'new terminal'); + await tester.pumpWidget(wrapInApp(controller: controller2)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + expect(semanticsValue(), isNot(contains('old terminal'))); + expect(semanticsValue(), contains('new terminal')); + } finally { + semantics.dispose(); + } + }); + testWidgets('changing controller reports focus loss to the old terminal', ( tester, ) async { diff --git a/packages/flterm/windows/CMakeLists.txt b/packages/flterm/windows/CMakeLists.txt new file mode 100644 index 00000000..a716d846 --- /dev/null +++ b/packages/flterm/windows/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) + +set(PROJECT_NAME "flterm") +project(${PROJECT_NAME} LANGUAGES CXX) +cmake_policy(VERSION 3.14...3.25) + +set(PLUGIN_NAME "flterm_plugin") + +add_library(${PLUGIN_NAME} SHARED + "include/flterm/flterm_plugin_c_api.h" + "flterm_plugin.cpp" + "flterm_plugin.h" + "flterm_plugin_c_api.cpp" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter + flutter_wrapper_plugin + Comctl32.lib +) + +set(flterm_bundled_libraries "" PARENT_SCOPE) diff --git a/packages/flterm/windows/flterm_plugin.cpp b/packages/flterm/windows/flterm_plugin.cpp new file mode 100644 index 00000000..650ce0a9 --- /dev/null +++ b/packages/flterm/windows/flterm_plugin.cpp @@ -0,0 +1,308 @@ +#include +#include + +#include "flterm_plugin.h" + +#include + +#include +#include +#include +#include + +namespace flterm { +namespace { + +constexpr char kChannelName[] = "dev.flterm/native_keyboard"; +constexpr UINT_PTR kSubclassId = 0x464C544D; +constexpr int kExtendedScanCode = 0xE000; +constexpr UINT kTranslateWithoutChangingState = 1 << 2; + +enum ModBits { + kShift = 1 << 0, + kControl = 1 << 1, + kAlt = 1 << 2, + kSuper = 1 << 3, + kCapsLock = 1 << 4, + kNumLock = 1 << 5, + kShiftSide = 1 << 6, + kControlSide = 1 << 7, + kAltSide = 1 << 8, + kSuperSide = 1 << 9, +}; + +bool IsDown(const std::array& state, int key) { + return (state[key] & 0x80) != 0; +} + +bool IsToggled(const std::array& state, int key) { + return (state[key] & 0x01) != 0; +} + +uint16_t ResolveKeyCode(WPARAM wparam, bool extended, uint8_t scan_code) { + switch (wparam) { + case VK_SHIFT: + return static_cast( + MapVirtualKey(scan_code, MAPVK_VSC_TO_VK_EX)); + case VK_CONTROL: + return extended ? VK_RCONTROL : VK_LCONTROL; + case VK_MENU: + return extended ? VK_RMENU : VK_LMENU; + default: + return static_cast(wparam); + } +} + +bool IsModifierKey(uint16_t key_code) { + switch (key_code) { + case VK_LSHIFT: + case VK_RSHIFT: + case VK_LCONTROL: + case VK_RCONTROL: + case VK_LMENU: + case VK_RMENU: + case VK_LWIN: + case VK_RWIN: + case VK_CAPITAL: + case VK_NUMLOCK: + case VK_SCROLL: + return true; + default: + return false; + } +} + +int64_t ModifierBits(const std::array& state) { + int64_t result = 0; + if (IsDown(state, VK_LSHIFT) || IsDown(state, VK_RSHIFT)) result |= kShift; + if (IsDown(state, VK_LCONTROL) || IsDown(state, VK_RCONTROL)) { + result |= kControl; + } + if (IsDown(state, VK_LMENU) || IsDown(state, VK_RMENU)) result |= kAlt; + if (IsDown(state, VK_LWIN) || IsDown(state, VK_RWIN)) result |= kSuper; + if (IsToggled(state, VK_CAPITAL)) result |= kCapsLock; + if (IsToggled(state, VK_NUMLOCK)) result |= kNumLock; + if (IsDown(state, VK_RSHIFT)) result |= kShiftSide; + if (IsDown(state, VK_RCONTROL)) result |= kControlSide; + if (IsDown(state, VK_RMENU)) result |= kAltSide; + if (IsDown(state, VK_RWIN)) result |= kSuperSide; + return result; +} + +struct Translation { + int result = 0; + std::wstring text; +}; + +Translation Translate(uint16_t key_code, + uint8_t scan_code, + const std::array& state, + HKL layout) { + std::array buffer{}; + const int result = ToUnicodeEx( + key_code, scan_code, state.data(), buffer.data(), + static_cast(buffer.size()), kTranslateWithoutChangingState, layout); + Translation translation; + translation.result = result; + if (result > 0) { + translation.text.assign(buffer.data(), result); + } + return translation; +} + +void ClearKey(std::array* state, int key) { + (*state)[key] = 0; +} + +void ClearShift(std::array* state) { + ClearKey(state, VK_SHIFT); + ClearKey(state, VK_LSHIFT); + ClearKey(state, VK_RSHIFT); +} + +void ClearControl(std::array* state) { + ClearKey(state, VK_CONTROL); + ClearKey(state, VK_LCONTROL); + ClearKey(state, VK_RCONTROL); +} + +void ClearAlt(std::array* state) { + ClearKey(state, VK_MENU); + ClearKey(state, VK_LMENU); + ClearKey(state, VK_RMENU); +} + +bool IsPrintable(const std::wstring& text) { + if (text.empty()) return false; + for (wchar_t value : text) { + if (value < 0x20 || value == 0x7F) return false; + } + return true; +} + +int64_t ConsumedModifierBits(uint16_t key_code, + uint8_t scan_code, + const std::array& state, + HKL layout, + const Translation& produced) { + if (produced.result <= 0 || !IsPrintable(produced.text)) return 0; + int64_t result = 0; + + if (IsDown(state, VK_LSHIFT) || IsDown(state, VK_RSHIFT)) { + auto without = state; + ClearShift(&without); + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kShift; + } + } + if (IsToggled(state, VK_CAPITAL)) { + auto without = state; + without[VK_CAPITAL] &= 0xFE; + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kCapsLock; + } + } + if (IsDown(state, VK_RMENU) && + (IsDown(state, VK_LCONTROL) || IsDown(state, VK_RCONTROL))) { + auto without = state; + ClearControl(&without); + ClearAlt(&without); + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kControl | kAlt; + } + } + return result; +} + +uint32_t SingleCodePoint(const std::wstring& text) { + if (text.size() == 1) { + const uint32_t value = text[0]; + if (value < 0x20 || value == 0x7F || + (value >= 0xD800 && value <= 0xDFFF)) { + return 0; + } + return value; + } + if (text.size() != 2) return 0; + const uint32_t high = text[0]; + const uint32_t low = text[1]; + if (high < 0xD800 || high > 0xDBFF || low < 0xDC00 || low > 0xDFFF) { + return 0; + } + return 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00); +} + +} // namespace + +void FltermPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + registrar->AddPlugin(std::make_unique(registrar)); +} + +FltermPlugin::FltermPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar), + channel_(std::make_unique< + flutter::BasicMessageChannel>( + registrar->messenger(), kChannelName, + &flutter::StandardMessageCodec::GetInstance())) { + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowMessage(window, message, wparam, lparam); + }); + if (auto* view = registrar_->GetView()) { + view_window_ = view->GetNativeWindow(); + SetWindowSubclass(view_window_, WindowSubclassProc, kSubclassId, + reinterpret_cast(this)); + } +} + +FltermPlugin::~FltermPlugin() { + if (view_window_ != nullptr) { + RemoveWindowSubclass(view_window_, WindowSubclassProc, kSubclassId); + } + if (window_proc_id_ >= 0) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); + } +} + +LRESULT CALLBACK FltermPlugin::WindowSubclassProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam, + UINT_PTR, + DWORD_PTR reference_data) { + auto* plugin = reinterpret_cast(reference_data); + plugin->HandleWindowMessage(window, message, wparam, lparam); + return DefSubclassProc(window, message, wparam, lparam); +} + +std::optional FltermPlugin::HandleWindowMessage(HWND, + UINT message, + WPARAM wparam, + LPARAM lparam) { + switch (message) { + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + case WM_KEYUP: + case WM_SYSKEYUP: + SendKeyboardMetadata(message, wparam, lparam); + break; + default: + break; + } + return std::nullopt; +} + +void FltermPlugin::SendKeyboardMetadata(UINT message, + WPARAM wparam, + LPARAM lparam) { + const bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; + const uint8_t scan_code = static_cast((lparam >> 16) & 0xFF); + const bool extended = ((lparam >> 24) & 0x01) != 0; + const uint16_t key_code = ResolveKeyCode(wparam, extended, scan_code); + if (wparam == VK_PACKET || IsModifierKey(key_code)) return; + + std::array state{}; + if (!GetKeyboardState(state.data())) return; + const HKL layout = GetKeyboardLayout(0); + const Translation produced = Translate(key_code, scan_code, state, layout); + + auto unshifted_state = state; + ClearShift(&unshifted_state); + ClearControl(&unshifted_state); + ClearAlt(&unshifted_state); + ClearKey(&unshifted_state, VK_LWIN); + ClearKey(&unshifted_state, VK_RWIN); + unshifted_state[VK_CAPITAL] &= 0xFE; + const Translation unshifted = + Translate(key_code, scan_code, unshifted_state, layout); + + flutter::EncodableMap message_map{ + {flutter::EncodableValue("platform"), + flutter::EncodableValue("windows")}, + {flutter::EncodableValue("scanCode"), + flutter::EncodableValue(scan_code | + (extended ? kExtendedScanCode : 0))}, + {flutter::EncodableValue("keyCode"), + flutter::EncodableValue(static_cast(key_code))}, + {flutter::EncodableValue("down"), flutter::EncodableValue(down)}, + {flutter::EncodableValue("eventTime"), + flutter::EncodableValue( + static_cast(static_cast(GetMessageTime())))}, + {flutter::EncodableValue("mods"), + flutter::EncodableValue(ModifierBits(state))}, + {flutter::EncodableValue("consumedMods"), + flutter::EncodableValue(ConsumedModifierBits( + key_code, scan_code, state, layout, produced))}, + {flutter::EncodableValue("unshiftedCodepoint"), + flutter::EncodableValue( + static_cast(SingleCodePoint(unshifted.text)))}, + {flutter::EncodableValue("textWithoutAlt"), + flutter::EncodableValue()}, + {flutter::EncodableValue("deadKey"), + flutter::EncodableValue(down && produced.result < 0)}, + }; + channel_->Send(flutter::EncodableValue(std::move(message_map))); +} + +} // namespace flterm diff --git a/packages/flterm/windows/flterm_plugin.h b/packages/flterm/windows/flterm_plugin.h new file mode 100644 index 00000000..ec5f9d15 --- /dev/null +++ b/packages/flterm/windows/flterm_plugin.h @@ -0,0 +1,47 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ + +#include +#include +#include + +#include +#include + +namespace flterm { + +class FltermPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar); + + explicit FltermPlugin(flutter::PluginRegistrarWindows* registrar); + ~FltermPlugin() override; + + FltermPlugin(const FltermPlugin&) = delete; + FltermPlugin& operator=(const FltermPlugin&) = delete; + + private: + static LRESULT CALLBACK WindowSubclassProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam, + UINT_PTR subclass_id, + DWORD_PTR reference_data); + + std::optional HandleWindowMessage(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam); + void SendKeyboardMetadata(UINT message, WPARAM wparam, LPARAM lparam); + + flutter::PluginRegistrarWindows* registrar_; + std::unique_ptr> + channel_; + HWND view_window_ = nullptr; + int window_proc_id_ = -1; +}; + +} // namespace flterm + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ diff --git a/packages/flterm/windows/flterm_plugin_c_api.cpp b/packages/flterm/windows/flterm_plugin_c_api.cpp new file mode 100644 index 00000000..1e8876eb --- /dev/null +++ b/packages/flterm/windows/flterm_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/flterm/flterm_plugin_c_api.h" + +#include + +#include "flterm_plugin.h" + +void FltermPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + flterm::FltermPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h b/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h new file mode 100644 index 00000000..5546f8b0 --- /dev/null +++ b/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void FltermPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_