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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/flterm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation

import flterm

func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FltermPlugin.register(with: registry.registrar(forPlugin: "FltermPlugin"))
}
4 changes: 4 additions & 0 deletions packages/flterm/lib/flterm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ export 'package:libghostty/libghostty.dart'
FormatterExtra,
FormatterFormat,
Key,
KeyAction,
Mods,
MouseTracking,
OptionAsAlt,
PointTag,
Position,
Scrollbar,
Expand All @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/flterm/lib/src/controller/terminal_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 8 additions & 5 deletions packages/flterm/lib/src/controller/terminal_controller_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/flterm/lib/src/foundation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
49 changes: 48 additions & 1 deletion packages/flterm/lib/src/foundation/platform_map.dart
Original file line number Diff line number Diff line change
@@ -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<int, Key> _codepointToKey = {
0x20: Key.space,
Expand Down Expand Up @@ -197,6 +197,31 @@ final Map<Key, int> _keyToCodepoint = {
Key.quote: 0x27,
Key.semicolon: 0x3b,
Key.slash: 0x2f,
Key.space: 0x20,
};

const _shiftedCodepoints = <int, int>{
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].
Expand All @@ -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;
}
12 changes: 12 additions & 0 deletions packages/flterm/lib/src/foundation/terminal_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -204,6 +212,7 @@ class TerminalConfig {
kittyImageStorageLimit,
apcBufferLimit,
glyphProtocol,
optionAsAlt,
cursorStyle,
cursorBlink,
.hashAllUnordered(modes.entries.map((e) => .hash(e.key, e.value))),
Expand All @@ -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) &&
Expand All @@ -241,6 +251,7 @@ class TerminalConfig {
int? kittyImageStorageLimit,
int? apcBufferLimit,
bool? glyphProtocol,
OptionAsAlt? optionAsAlt,
CursorShape? cursorStyle,
bool? cursorBlink,
Map<TerminalMode, bool>? modes,
Expand All @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions packages/flterm/lib/src/foundation/terminal_keyboard_event.dart
Original file line number Diff line number Diff line change
@@ -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,
);
Loading