From 9c1597a45036085eb9d9ef2a03a108c75ce62d08 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 5 May 2026 08:50:38 +0200 Subject: [PATCH] inline terminal emulator, drop xterm.dart dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the xterm pub.dev package with owned code under lib/src/terminal/. Based on xterm.dart v4.0.0 by xuty (MIT). Quiver LRU replaced with hand-rolled LinkedHashMap cache. Scrollable removed from TerminalView — scroll events are forwarded via Listener.onPointerSignal instead. zmodem, debugger, and suggestion modules stripped as unused. Also: bundle clide.tmux.conf (no status bar, 50k scrollback, mouse on, zero escape delay, isolated -L clide socket), bump PTY read buffer to 64KB, add 2px terminal padding, drop bold JetBrains Mono registration. Co-Authored-By: Claude --- .pql/pql-plan.json | 2 +- CHANGELOG.md | 12 + assets/clide.tmux.conf | 35 + lib/builtin/terminal/src/terminal_pane.dart | 2 +- lib/src/pty/session.dart | 2 +- lib/src/terminal/LICENSE | 21 + lib/src/terminal/src/base/disposable.dart | 43 + lib/src/terminal/src/base/event.dart | 44 + lib/src/terminal/src/base/observable.dart | 19 + lib/src/terminal/src/core/buffer/buffer.dart | 606 +++++++++ .../terminal/src/core/buffer/cell_flags.dart | 11 + .../terminal/src/core/buffer/cell_offset.dart | 57 + lib/src/terminal/src/core/buffer/line.dart | 420 ++++++ lib/src/terminal/src/core/buffer/range.dart | 57 + .../terminal/src/core/buffer/range_block.dart | 113 ++ .../terminal/src/core/buffer/range_line.dart | 68 + lib/src/terminal/src/core/buffer/segment.dart | 60 + lib/src/terminal/src/core/cell.dart | 68 + lib/src/terminal/src/core/charset.dart | 97 ++ lib/src/terminal/src/core/color.dart | 21 + lib/src/terminal/src/core/cursor.dart | 139 ++ lib/src/terminal/src/core/escape/emitter.dart | 35 + lib/src/terminal/src/core/escape/handler.dart | 217 ++++ lib/src/terminal/src/core/escape/parser.dart | 1156 +++++++++++++++++ lib/src/terminal/src/core/input/handler.dart | 212 +++ lib/src/terminal/src/core/input/keys.dart | 837 ++++++++++++ .../src/core/input/keytab/keytab.dart | 115 ++ .../src/core/input/keytab/keytab_default.dart | 215 +++ .../src/core/input/keytab/keytab_escape.dart | 23 + .../src/core/input/keytab/keytab_parse.dart | 188 +++ .../src/core/input/keytab/keytab_record.dart | 136 ++ .../src/core/input/keytab/keytab_token.dart | 181 +++ .../src/core/input/keytab/qt_keyname.dart | 479 +++++++ lib/src/terminal/src/core/mouse/button.dart | 31 + .../terminal/src/core/mouse/button_state.dart | 7 + lib/src/terminal/src/core/mouse/handler.dart | 116 ++ lib/src/terminal/src/core/mouse/mode.dart | 38 + lib/src/terminal/src/core/mouse/reporter.dart | 50 + lib/src/terminal/src/core/platform.dart | 19 + lib/src/terminal/src/core/reflow.dart | 199 +++ lib/src/terminal/src/core/snapshot.dart | 5 + lib/src/terminal/src/core/state.dart | 46 + lib/src/terminal/src/core/tabs.dart | 70 + lib/src/terminal/src/terminal.dart | 908 +++++++++++++ lib/src/terminal/src/terminal_view.dart | 545 ++++++++ lib/src/terminal/src/ui/char_metrics.dart | 23 + lib/src/terminal/src/ui/controller.dart | 176 +++ lib/src/terminal/src/ui/cursor_type.dart | 9 + lib/src/terminal/src/ui/custom_text_edit.dart | 283 ++++ .../src/ui/gesture/gesture_detector.dart | 164 +++ .../src/ui/gesture/gesture_handler.dart | 193 +++ .../terminal/src/ui/infinite_scroll_view.dart | 117 ++ lib/src/terminal/src/ui/input_map.dart | 290 +++++ .../terminal/src/ui/keyboard_listener.dart | 51 + .../terminal/src/ui/keyboard_visibility.dart | 60 + lib/src/terminal/src/ui/painter.dart | 281 ++++ lib/src/terminal/src/ui/palette_builder.dart | 120 ++ lib/src/terminal/src/ui/paragraph_cache.dart | 58 + lib/src/terminal/src/ui/pointer_input.dart | 31 + lib/src/terminal/src/ui/render.dart | 554 ++++++++ lib/src/terminal/src/ui/scroll_handler.dart | 102 ++ lib/src/terminal/src/ui/selection_mode.dart | 7 + lib/src/terminal/src/ui/shortcut/actions.dart | 73 ++ .../terminal/src/ui/shortcut/shortcuts.dart | 36 + lib/src/terminal/src/ui/terminal_size.dart | 26 + .../terminal/src/ui/terminal_text_style.dart | 89 ++ lib/src/terminal/src/ui/terminal_theme.dart | 59 + lib/src/terminal/src/ui/themes.dart | 58 + lib/src/terminal/src/utils/ascii.dart | 405 ++++++ lib/src/terminal/src/utils/byte_consumer.dart | 109 ++ lib/src/terminal/src/utils/char_code.dart | 7 + .../terminal/src/utils/circular_buffer.dart | 317 +++++ lib/src/terminal/src/utils/hash_values.dart | 113 ++ lib/src/terminal/src/utils/lookup_table.dart | 36 + lib/src/terminal/src/utils/unicode_v11.dart | 514 ++++++++ lib/src/terminal/terminal.dart | 36 + lib/widgets/src/clide_pty_view.dart | 4 +- pubspec.lock | 24 - pubspec.yaml | 8 +- 79 files changed, 12122 insertions(+), 36 deletions(-) create mode 100644 assets/clide.tmux.conf create mode 100644 lib/src/terminal/LICENSE create mode 100644 lib/src/terminal/src/base/disposable.dart create mode 100644 lib/src/terminal/src/base/event.dart create mode 100644 lib/src/terminal/src/base/observable.dart create mode 100644 lib/src/terminal/src/core/buffer/buffer.dart create mode 100644 lib/src/terminal/src/core/buffer/cell_flags.dart create mode 100644 lib/src/terminal/src/core/buffer/cell_offset.dart create mode 100644 lib/src/terminal/src/core/buffer/line.dart create mode 100644 lib/src/terminal/src/core/buffer/range.dart create mode 100644 lib/src/terminal/src/core/buffer/range_block.dart create mode 100644 lib/src/terminal/src/core/buffer/range_line.dart create mode 100644 lib/src/terminal/src/core/buffer/segment.dart create mode 100644 lib/src/terminal/src/core/cell.dart create mode 100644 lib/src/terminal/src/core/charset.dart create mode 100644 lib/src/terminal/src/core/color.dart create mode 100644 lib/src/terminal/src/core/cursor.dart create mode 100644 lib/src/terminal/src/core/escape/emitter.dart create mode 100644 lib/src/terminal/src/core/escape/handler.dart create mode 100644 lib/src/terminal/src/core/escape/parser.dart create mode 100644 lib/src/terminal/src/core/input/handler.dart create mode 100644 lib/src/terminal/src/core/input/keys.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab_default.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab_escape.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab_parse.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab_record.dart create mode 100644 lib/src/terminal/src/core/input/keytab/keytab_token.dart create mode 100644 lib/src/terminal/src/core/input/keytab/qt_keyname.dart create mode 100644 lib/src/terminal/src/core/mouse/button.dart create mode 100644 lib/src/terminal/src/core/mouse/button_state.dart create mode 100644 lib/src/terminal/src/core/mouse/handler.dart create mode 100644 lib/src/terminal/src/core/mouse/mode.dart create mode 100644 lib/src/terminal/src/core/mouse/reporter.dart create mode 100644 lib/src/terminal/src/core/platform.dart create mode 100644 lib/src/terminal/src/core/reflow.dart create mode 100644 lib/src/terminal/src/core/snapshot.dart create mode 100644 lib/src/terminal/src/core/state.dart create mode 100644 lib/src/terminal/src/core/tabs.dart create mode 100644 lib/src/terminal/src/terminal.dart create mode 100644 lib/src/terminal/src/terminal_view.dart create mode 100644 lib/src/terminal/src/ui/char_metrics.dart create mode 100644 lib/src/terminal/src/ui/controller.dart create mode 100644 lib/src/terminal/src/ui/cursor_type.dart create mode 100644 lib/src/terminal/src/ui/custom_text_edit.dart create mode 100644 lib/src/terminal/src/ui/gesture/gesture_detector.dart create mode 100644 lib/src/terminal/src/ui/gesture/gesture_handler.dart create mode 100644 lib/src/terminal/src/ui/infinite_scroll_view.dart create mode 100644 lib/src/terminal/src/ui/input_map.dart create mode 100644 lib/src/terminal/src/ui/keyboard_listener.dart create mode 100644 lib/src/terminal/src/ui/keyboard_visibility.dart create mode 100644 lib/src/terminal/src/ui/painter.dart create mode 100644 lib/src/terminal/src/ui/palette_builder.dart create mode 100644 lib/src/terminal/src/ui/paragraph_cache.dart create mode 100644 lib/src/terminal/src/ui/pointer_input.dart create mode 100644 lib/src/terminal/src/ui/render.dart create mode 100644 lib/src/terminal/src/ui/scroll_handler.dart create mode 100644 lib/src/terminal/src/ui/selection_mode.dart create mode 100644 lib/src/terminal/src/ui/shortcut/actions.dart create mode 100644 lib/src/terminal/src/ui/shortcut/shortcuts.dart create mode 100644 lib/src/terminal/src/ui/terminal_size.dart create mode 100644 lib/src/terminal/src/ui/terminal_text_style.dart create mode 100644 lib/src/terminal/src/ui/terminal_theme.dart create mode 100644 lib/src/terminal/src/ui/themes.dart create mode 100644 lib/src/terminal/src/utils/ascii.dart create mode 100644 lib/src/terminal/src/utils/byte_consumer.dart create mode 100644 lib/src/terminal/src/utils/char_code.dart create mode 100644 lib/src/terminal/src/utils/circular_buffer.dart create mode 100644 lib/src/terminal/src/utils/hash_values.dart create mode 100644 lib/src/terminal/src/utils/lookup_table.dart create mode 100644 lib/src/terminal/src/utils/unicode_v11.dart create mode 100644 lib/src/terminal/terminal.dart diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index c7a100ec..1eebc85c 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-05T06:49:53Z", + "exported_at": "2026-05-05T06:50:38Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b59418a..15ccf798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ## [Unreleased] +### Changed + +- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the + pub.dev dependency with owned code under `lib/src/terminal/`. Drops + three transitive dependencies (xterm, quiver, zmodem). +- Bundle clide-specific tmux.conf for Claude pane sessions: no status + bar, 50k scrollback, mouse on, zero escape delay, isolated socket. +- PTY read buffer increased from 4KB to 64KB. +- Terminal view 2px padding on all sides. +- Remove bold JetBrains Mono font registration to prevent glyph width + mismatch in terminal rendering. + ## [2.0.0] — 2026-05-03 ### Fixed diff --git a/assets/clide.tmux.conf b/assets/clide.tmux.conf new file mode 100644 index 00000000..1c73c407 --- /dev/null +++ b/assets/clide.tmux.conf @@ -0,0 +1,35 @@ +# clide tmux.conf — loaded via tmux -f for every Claude pane session. +# Tuned for embedding inside xterm.dart; no status bar, large +# scrollback, mouse-scroll passthrough, zero escape delay. + +# No status bar — clide renders its own pane chrome. +set -g status off + +# 50k lines of scrollback (tmux default is 2000). +set -g history-limit 50000 + +# Zero escape delay — xterm.dart delivers escape sequences +# atomically, so the 500ms default just adds latency. +set -sg escape-time 0 + +# Mouse on — scroll wheel events reach tmux's copy-mode so the +# user can scroll back through Claude output. +set -g mouse on + +# 256color + true-color passthrough. +set -g default-terminal "xterm-256color" +set -ga terminal-overrides ",xterm-256color:Tc" + +# Don't ring the bell visually or audibly — clide owns notifications. +set -g visual-bell off +set -g bell-action none + +# Keep the session alive when the shell exits — clide manages +# lifecycle via pane.close, not tmux session destruction. +set -g remain-on-exit off + +# Allow alt-screen passthrough for full-screen programs. +set -g alternate-screen on + +# Focus events let the terminal's focus tracking work through tmux. +set -g focus-events on diff --git a/lib/builtin/terminal/src/terminal_pane.dart b/lib/builtin/terminal/src/terminal_pane.dart index 211eb3c8..6b24699d 100644 --- a/lib/builtin/terminal/src/terminal_pane.dart +++ b/lib/builtin/terminal/src/terminal_pane.dart @@ -7,7 +7,7 @@ import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; -import 'package:xterm/xterm.dart'; +import 'package:clide/src/terminal/terminal.dart'; /// General-purpose terminal pane. Spawns the user's `$SHELL` under the /// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event diff --git a/lib/src/pty/session.dart b/lib/src/pty/session.dart index aa9c16a3..62ad959b 100644 --- a/lib/src/pty/session.dart +++ b/lib/src/pty/session.dart @@ -370,7 +370,7 @@ class _ReaderArgs { /// each chunk back to the main isolate as a `Uint8List`. Exits on /// EOF, close, or error. void _readerEntrypoint(_ReaderArgs args) { - const chunk = 4096; + const chunk = 65536; final buf = pkg_ffi.calloc(chunk); try { while (true) { diff --git a/lib/src/terminal/LICENSE b/lib/src/terminal/LICENSE new file mode 100644 index 00000000..7a5fd3e8 --- /dev/null +++ b/lib/src/terminal/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 xuty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/src/terminal/src/base/disposable.dart b/lib/src/terminal/src/base/disposable.dart new file mode 100644 index 00000000..69968c01 --- /dev/null +++ b/lib/src/terminal/src/base/disposable.dart @@ -0,0 +1,43 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/base/event.dart'; + +mixin Disposable { + final _disposables = []; + + bool get disposed => _disposed; + bool _disposed = false; + + Event get onDisposed => _onDisposed.event; + final _onDisposed = EventEmitter(); + + void register(Disposable disposable) { + assert(!_disposed); + _disposables.add(disposable); + } + + void registerCallback(void Function() callback) { + assert(!_disposed); + _disposables.add(_DisposeCallback(callback)); + } + + void dispose() { + _disposed = true; + for (final disposable in _disposables) { + disposable.dispose(); + } + _onDisposed.emit(null); + } +} + +class _DisposeCallback with Disposable { + final void Function() callback; + + _DisposeCallback(this.callback); + + @override + void dispose() { + super.dispose(); + callback(); + } +} diff --git a/lib/src/terminal/src/base/event.dart b/lib/src/terminal/src/base/event.dart new file mode 100644 index 00000000..ebf90c1b --- /dev/null +++ b/lib/src/terminal/src/base/event.dart @@ -0,0 +1,44 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/base/disposable.dart'; + +typedef EventListener = void Function(T event); + +class Event { + final EventEmitter emitter; + + Event(this.emitter); + + void call(EventListener listener) { + emitter(listener); + } +} + +class EventEmitter { + final _listeners = >[]; + + EventSubscription call(EventListener listener) { + _listeners.add(listener); + return EventSubscription(this, listener); + } + + void emit(T event) { + for (final listener in _listeners) { + listener(event); + } + } + + Event get event => Event(this); +} + +class EventSubscription with Disposable { + final EventEmitter emitter; + final EventListener listener; + + EventSubscription(this.emitter, this.listener); + + @override + void dispose() { + emitter._listeners.remove(listener); + } +} diff --git a/lib/src/terminal/src/base/observable.dart b/lib/src/terminal/src/base/observable.dart new file mode 100644 index 00000000..91e98da9 --- /dev/null +++ b/lib/src/terminal/src/base/observable.dart @@ -0,0 +1,19 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +mixin Observable { + final listeners = {}; + + void addListener(void Function() listener) { + listeners.add(listener); + } + + void removeListener(void Function() listener) { + listeners.remove(listener); + } + + void notifyListeners() { + for (var listener in listeners) { + listener(); + } + } +} diff --git a/lib/src/terminal/src/core/buffer/buffer.dart b/lib/src/terminal/src/core/buffer/buffer.dart new file mode 100644 index 00000000..1b92640e --- /dev/null +++ b/lib/src/terminal/src/core/buffer/buffer.dart @@ -0,0 +1,606 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show max, min; + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/line.dart'; +import 'package:clide/src/terminal/src/core/buffer/range_line.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; +import 'package:clide/src/terminal/src/core/charset.dart'; +import 'package:clide/src/terminal/src/core/cursor.dart'; +import 'package:clide/src/terminal/src/core/reflow.dart'; +import 'package:clide/src/terminal/src/core/state.dart'; +import 'package:clide/src/terminal/src/utils/circular_buffer.dart'; +import 'package:clide/src/terminal/src/utils/unicode_v11.dart'; + +class Buffer { + final TerminalState terminal; + + final int maxLines; + + final bool isAltBuffer; + + /// Characters that break selection when calling [getWordBoundary]. If null, + /// defaults to [defaultWordSeparators]. + final Set? wordSeparators; + + Buffer( + this.terminal, { + required this.maxLines, + required this.isAltBuffer, + this.wordSeparators, + }) { + for (int i = 0; i < terminal.viewHeight; i++) { + lines.push(_newEmptyLine()); + } + + resetVerticalMargins(); + } + + int _cursorX = 0; + + int _cursorY = 0; + + late int _marginTop; + + late int _marginBottom; + + var _savedCursorX = 0; + + var _savedCursorY = 0; + + final _savedCursorStyle = CursorStyle(); + + final charset = Charset(); + + /// Width of the viewport in columns. Also the index of the last column. + int get viewWidth => terminal.viewWidth; + + /// Height of the viewport in rows. Also the index of the last line. + int get viewHeight => terminal.viewHeight; + + /// lines of the buffer. the length of [lines] should always be equal or + /// greater than [viewHeight]. + late final lines = IndexAwareCircularBuffer(maxLines); + + /// Total number of lines in the buffer. Always equal or greater than + /// [viewHeight]. + int get height => lines.length; + + /// Horizontal position of the cursor relative to the top-left cornor of the + /// screen, starting from 0. + int get cursorX => _cursorX.clamp(0, terminal.viewWidth - 1); + + /// Vertical position of the cursor relative to the top-left cornor of the + /// screen, starting from 0. + int get cursorY => _cursorY; + + /// Index of the first line in the scroll region. + int get marginTop => _marginTop; + + /// Index of the last line in the scroll region. + int get marginBottom => _marginBottom; + + /// The number of lines above the viewport. + int get scrollBack => height - viewHeight; + + /// Vertical position of the cursor relative to the top of the buffer, + /// starting from 0. + int get absoluteCursorY => _cursorY + scrollBack; + + /// Absolute index of the first line in the scroll region. + int get absoluteMarginTop => _marginTop + scrollBack; + + /// Absolute index of the last line in the scroll region. + int get absoluteMarginBottom => _marginBottom + scrollBack; + + /// Writes data to the _terminal. Terminal sequences or special characters are + /// not interpreted and directly added to the buffer. + /// + /// See also: [Terminal.write] + void write(String text) { + for (var char in text.runes) { + writeChar(char); + } + } + + /// Writes a single character to the _terminal. Escape sequences or special + /// characters are not interpreted and directly added to the buffer. + /// + /// See also: [Terminal.writeChar] + void writeChar(int codePoint) { + codePoint = charset.translate(codePoint); + + final cellWidth = unicodeV11.wcwidth(codePoint); + if (_cursorX >= terminal.viewWidth) { + index(); + setCursorX(0); + if (terminal.autoWrapMode) { + currentLine.isWrapped = true; + } + } + + final line = currentLine; + line.setCell(_cursorX, codePoint, cellWidth, terminal.cursor); + + if (_cursorX < viewWidth) { + _cursorX++; + } + + if (cellWidth == 2) { + writeChar(0); + } + } + + /// The line at the current cursor position. + BufferLine get currentLine { + return lines[absoluteCursorY]; + } + + void backspace() { + if (_cursorX == 0 && currentLine.isWrapped) { + currentLine.isWrapped = false; + moveCursor(viewWidth - 1, -1); + } else if (_cursorX == viewWidth) { + moveCursor(-2, 0); + } else { + moveCursor(-1, 0); + } + } + + /// Erases the viewport from the cursor position to the end of the buffer, + /// including the cursor position. + void eraseDisplayFromCursor() { + eraseLineFromCursor(); + + for (var i = absoluteCursorY + 1; i < height; i++) { + final line = lines[i]; + line.isWrapped = false; + line.eraseRange(0, viewWidth, terminal.cursor); + } + } + + /// Erases the viewport from the top-left corner to the cursor, including the + /// cursor. + void eraseDisplayToCursor() { + eraseLineToCursor(); + + for (var i = 0; i < _cursorY; i++) { + final line = lines[i + scrollBack]; + line.isWrapped = false; + line.eraseRange(0, viewWidth, terminal.cursor); + } + } + + /// Erases the whole viewport. + void eraseDisplay() { + for (var i = 0; i < viewHeight; i++) { + final line = lines[i + scrollBack]; + line.isWrapped = false; + line.eraseRange(0, viewWidth, terminal.cursor); + } + } + + /// Erases the line from the cursor to the end of the line, including the + /// cursor position. + void eraseLineFromCursor() { + currentLine.isWrapped = false; + currentLine.eraseRange(_cursorX, viewWidth, terminal.cursor); + } + + /// Erases the line from the start of the line to the cursor, including the + /// cursor. + void eraseLineToCursor() { + currentLine.isWrapped = false; + currentLine.eraseRange(0, _cursorX, terminal.cursor); + } + + /// Erases the line at the current cursor position. + void eraseLine() { + currentLine.isWrapped = false; + currentLine.eraseRange(0, viewWidth, terminal.cursor); + } + + /// Erases [count] cells starting at the cursor position. + void eraseChars(int count) { + final start = _cursorX; + currentLine.eraseRange(start, start + count, terminal.cursor); + } + + void scrollDown(int lines) { + for (var i = absoluteMarginBottom; i >= absoluteMarginTop; i--) { + if (i >= absoluteMarginTop + lines) { + this.lines[i] = this.lines[i - lines]; + } else { + this.lines[i] = _newEmptyLine(); + } + } + } + + void scrollUp(int lines) { + for (var i = absoluteMarginTop; i <= absoluteMarginBottom; i++) { + if (i <= absoluteMarginBottom - lines) { + this.lines[i] = this.lines[i + lines]; + } else { + this.lines[i] = _newEmptyLine(); + } + } + } + + /// https://vt100.net/docs/vt100-ug/chapter3.html#IND IND – Index + /// + /// ESC D + /// + /// [index] causes the active position to move downward one line without + /// changing the column position. If the active position is at the bottom + /// margin, a scroll up is performed. + void index() { + if (isInVerticalMargin) { + if (_cursorY == _marginBottom) { + if (marginTop == 0 && !isAltBuffer) { + lines.insert(absoluteMarginBottom + 1, _newEmptyLine()); + } else { + scrollUp(1); + } + } else { + moveCursorY(1); + } + return; + } + + // the cursor is not in the scrollable region + if (_cursorY >= viewHeight - 1) { + // we are at the bottom + if (isAltBuffer) { + scrollUp(1); + } else { + lines.push(_newEmptyLine()); + } + } else { + // there're still lines so we simply move cursor down. + moveCursorY(1); + } + } + + void lineFeed() { + index(); + if (terminal.lineFeedMode) { + setCursorX(0); + } + } + + /// https://terminalguide.namepad.de/seq/a_esc_cm/ + void reverseIndex() { + if (isInVerticalMargin) { + if (_cursorY == _marginTop) { + scrollDown(1); + } else { + moveCursorY(-1); + } + } else { + moveCursorY(-1); + } + } + + void cursorGoForward() { + _cursorX = min(_cursorX + 1, viewWidth); + } + + void setCursorX(int cursorX) { + _cursorX = cursorX.clamp(0, viewWidth - 1); + } + + void setCursorY(int cursorY) { + _cursorY = cursorY.clamp(0, viewHeight - 1); + } + + void moveCursorX(int offset) { + setCursorX(_cursorX + offset); + } + + void moveCursorY(int offset) { + setCursorY(_cursorY + offset); + } + + void setCursor(int cursorX, int cursorY) { + var maxCursorY = viewHeight - 1; + + if (terminal.originMode) { + cursorY += _marginTop; + maxCursorY = _marginBottom; + } + + _cursorX = cursorX.clamp(0, viewWidth - 1); + _cursorY = cursorY.clamp(0, maxCursorY); + } + + void moveCursor(int offsetX, int offsetY) { + final cursorX = _cursorX + offsetX; + final cursorY = _cursorY + offsetY; + setCursor(cursorX, cursorY); + } + + /// Save cursor position, charmap and text attributes. + void saveCursor() { + _savedCursorX = _cursorX; + _savedCursorY = _cursorY; + _savedCursorStyle.foreground = terminal.cursor.foreground; + _savedCursorStyle.background = terminal.cursor.background; + _savedCursorStyle.attrs = terminal.cursor.attrs; + charset.save(); + } + + /// Restore cursor position, charmap and text attributes. + void restoreCursor() { + _cursorX = _savedCursorX; + _cursorY = _savedCursorY; + terminal.cursor.foreground = _savedCursorStyle.foreground; + terminal.cursor.background = _savedCursorStyle.background; + terminal.cursor.attrs = _savedCursorStyle.attrs; + charset.restore(); + } + + /// Sets the vertical scrolling margin to [top] and [bottom]. + /// Both values must be between 0 and [viewHeight] - 1. + void setVerticalMargins(int top, int bottom) { + _marginTop = top.clamp(0, viewHeight - 1); + _marginBottom = bottom.clamp(0, viewHeight - 1); + + _marginTop = min(_marginTop, _marginBottom); + _marginBottom = max(_marginTop, _marginBottom); + } + + bool get isInVerticalMargin { + return _cursorY >= _marginTop && _cursorY <= _marginBottom; + } + + void resetVerticalMargins() { + setVerticalMargins(0, viewHeight - 1); + } + + void deleteChars(int count) { + final start = _cursorX.clamp(0, viewWidth); + count = min(count, viewWidth - start); + currentLine.removeCells(start, count, terminal.cursor); + } + + /// Remove all lines above the top of the viewport. + void clearScrollback() { + if (height <= viewHeight) { + return; + } + + lines.trimStart(scrollBack); + } + + /// Clears the viewport and scrollback buffer. Then fill with empty lines. + void clear() { + lines.clear(); + for (int i = 0; i < viewHeight; i++) { + lines.push(_newEmptyLine()); + } + } + + void insertBlankChars(int count) { + currentLine.insertCells(_cursorX, count, terminal.cursor); + } + + void insertLines(int count) { + if (!isInVerticalMargin) { + return; + } + + setCursorX(0); + + // Number of lines from the cursor to the bottom of the scrollable region + // including the cursor itself. + final linesBelow = absoluteMarginBottom - absoluteCursorY + 1; + + // Number of empty lines to insert. + final linesToInsert = min(count, linesBelow); + + // Number of lines to move up. + final linesToMove = linesBelow - linesToInsert; + + for (var i = 0; i < linesToMove; i++) { + final index = absoluteMarginBottom - i; + lines[index] = lines.swap(index - linesToInsert, _newEmptyLine()); + } + + for (var i = linesToMove; i < linesToInsert; i++) { + lines[absoluteCursorY + i] = _newEmptyLine(); + } + } + + /// Remove [count] lines starting at the current cursor position. Lines below + /// the removed lines are shifted up. This only affects the scrollable region. + /// Lines outside the scrollable region are not affected. + void deleteLines(int count) { + if (!isInVerticalMargin) { + return; + } + + setCursorX(0); + + count = min(count, absoluteMarginBottom - absoluteCursorY + 1); + + final linesToMove = absoluteMarginBottom - absoluteCursorY + 1 - count; + + for (var i = 0; i < linesToMove; i++) { + final index = absoluteCursorY + i; + lines[index] = lines[index + count]; + } + + for (var i = 0; i < count; i++) { + lines[absoluteMarginBottom - i] = _newEmptyLine(); + } + } + + void resize(int oldWidth, int oldHeight, int newWidth, int newHeight) { + // 1. Adjust the height. + if (newHeight > oldHeight) { + // Grow larger + for (var i = 0; i < newHeight - oldHeight; i++) { + if (newHeight > lines.length) { + lines.push(_newEmptyLine(newWidth)); + } else { + _cursorY++; + } + } + } else { + // Shrink smaller + for (var i = 0; i < oldHeight - newHeight; i++) { + if (_cursorY > newHeight - 1) { + _cursorY--; + } else { + lines.pop(); + } + } + } + + // Ensure cursor is within the screen. + _cursorX = _cursorX.clamp(0, newWidth - 1); + _cursorY = _cursorY.clamp(0, newHeight - 1); + + // 2. Adjust the width. + if (newWidth != oldWidth) { + if (terminal.reflowEnabled && !isAltBuffer) { + final reflowResult = reflow(lines, oldWidth, newWidth); + + while (reflowResult.length < newHeight) { + reflowResult.add(_newEmptyLine(newWidth)); + } + + lines.replaceWith(reflowResult); + } else { + lines.forEach((item) => item.resize(newWidth)); + } + } + } + + /// Create a new [CellAnchor] at the specified [x] and [y] coordinates. + CellAnchor createAnchor(int x, int y) { + return lines[y].createAnchor(x); + } + + /// Create a new [CellAnchor] at the specified [x] and [y] coordinates. + CellAnchor createAnchorFromOffset(CellOffset offset) { + return lines[offset.y].createAnchor(offset.x); + } + + CellAnchor createAnchorFromCursor() { + return createAnchor(cursorX, absoluteCursorY); + } + + /// Create a new empty [BufferLine] with the current [viewWidth] if [width] + /// is not specified. + BufferLine _newEmptyLine([int? width]) { + final line = BufferLine(width ?? viewWidth); + return line; + } + + static final defaultWordSeparators = { + 0, + r' '.codeUnitAt(0), + r'.'.codeUnitAt(0), + r':'.codeUnitAt(0), + r'-'.codeUnitAt(0), + r'\'.codeUnitAt(0), + r'"'.codeUnitAt(0), + r'*'.codeUnitAt(0), + r'+'.codeUnitAt(0), + r'/'.codeUnitAt(0), + r'\'.codeUnitAt(0), + }; + + BufferRangeLine? getWordBoundary(CellOffset position) { + var separators = wordSeparators ?? defaultWordSeparators; + if (position.y >= lines.length) { + return null; + } + + var line = lines[position.y]; + var start = position.x; + var end = position.x; + + do { + if (start == 0) { + break; + } + final char = line.getCodePoint(start - 1); + if (separators.contains(char)) { + break; + } + start--; + } while (true); + + do { + if (end >= viewWidth) { + break; + } + final char = line.getCodePoint(end); + if (separators.contains(char)) { + break; + } + end++; + } while (true); + + if (start == end) { + return null; + } + + return BufferRangeLine( + CellOffset(start, position.y), + CellOffset(end, position.y), + ); + } + + /// Get the plain text content of the buffer including the scrollback. + /// Accepts an optional [range] to get a specific part of the buffer. + String getText([BufferRange? range]) { + range ??= BufferRangeLine( + CellOffset(0, 0), + CellOffset(viewWidth - 1, height - 1), + ); + + range = range.normalized; + + final builder = StringBuffer(); + + for (var segment in range.toSegments()) { + if (segment.line < 0 || segment.line >= height) { + continue; + } + final line = lines[segment.line]; + if (!(segment.line == range.begin.y || + segment.line == 0 || + line.isWrapped)) { + builder.write("\n"); + } + builder.write(line.getText(segment.start, segment.end)); + } + + return builder.toString(); + } + + /// Returns a debug representation of the buffer. + @override + String toString() { + final builder = StringBuffer(); + final lineNumberLength = lines.length.toString().length; + + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + + builder.write('${i.toString().padLeft(lineNumberLength)}: |${lines[i]}|'); + + if (line.isWrapped) { + builder.write(' (⏎)'); + } + + builder.write('\n'); + } + + return builder.toString(); + } +} diff --git a/lib/src/terminal/src/core/buffer/cell_flags.dart b/lib/src/terminal/src/core/buffer/cell_flags.dart new file mode 100644 index 00000000..da9adc9d --- /dev/null +++ b/lib/src/terminal/src/core/buffer/cell_flags.dart @@ -0,0 +1,11 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +abstract class CellFlags { + static const bold = 1 << 0; + static const faint = 1 << 1; + static const italic = 1 << 2; + static const underline = 1 << 3; + static const blink = 1 << 4; + static const inverse = 1 << 5; + static const invisible = 1 << 6; +} diff --git a/lib/src/terminal/src/core/buffer/cell_offset.dart b/lib/src/terminal/src/core/buffer/cell_offset.dart new file mode 100644 index 00000000..6c23194f --- /dev/null +++ b/lib/src/terminal/src/core/buffer/cell_offset.dart @@ -0,0 +1,57 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/range.dart'; + +class CellOffset { + final int x; + + final int y; + + const CellOffset(this.x, this.y); + + bool isEqual(CellOffset other) { + return other.x == x && other.y == y; + } + + bool isBefore(CellOffset other) { + return y < other.y || (y == other.y && x < other.x); + } + + bool isAfter(CellOffset other) { + return y > other.y || (y == other.y && x > other.x); + } + + bool isBeforeOrSame(CellOffset other) { + return y < other.y || (y == other.y && x <= other.x); + } + + bool isAfterOrSame(CellOffset other) { + return y > other.y || (y == other.y && x >= other.x); + } + + bool isAtSameRow(CellOffset other) { + return y == other.y; + } + + bool isAtSameColumn(CellOffset other) { + return x == other.x; + } + + bool isWithin(BufferRange range) { + return range.contains(this); + } + + @override + String toString() => 'CellOffset($x, $y)'; + + @override + int get hashCode => x.hashCode ^ y.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CellOffset && + runtimeType == other.runtimeType && + x == other.x && + y == other.y; +} diff --git a/lib/src/terminal/src/core/buffer/line.dart b/lib/src/terminal/src/core/buffer/line.dart new file mode 100644 index 00000000..749a4a86 --- /dev/null +++ b/lib/src/terminal/src/core/buffer/line.dart @@ -0,0 +1,420 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show min; +import 'dart:typed_data'; + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/cell.dart'; +import 'package:clide/src/terminal/src/core/cursor.dart'; +import 'package:clide/src/terminal/src/utils/circular_buffer.dart'; +import 'package:clide/src/terminal/src/utils/unicode_v11.dart'; + +const _cellSize = 4; + +const _cellForeground = 0; + +const _cellBackground = 1; + +const _cellAttributes = 2; + +const _cellContent = 3; + +class BufferLine with IndexedItem { + BufferLine( + this._length, { + this.isWrapped = false, + }) : _data = Uint32List(_calcCapacity(_length) * _cellSize); + + int _length; + + Uint32List _data; + + Uint32List get data => _data; + + var isWrapped = false; + + int get length => _length; + + final _anchors = []; + + List get anchors => _anchors; + + int getForeground(int index) { + return _data[index * _cellSize + _cellForeground]; + } + + int getBackground(int index) { + return _data[index * _cellSize + _cellBackground]; + } + + int getAttributes(int index) { + return _data[index * _cellSize + _cellAttributes]; + } + + int getContent(int index) { + return _data[index * _cellSize + _cellContent]; + } + + int getCodePoint(int index) { + return _data[index * _cellSize + _cellContent] & CellContent.codepointMask; + } + + int getWidth(int index) { + return _data[index * _cellSize + _cellContent] >> CellContent.widthShift; + } + + void getCellData(int index, CellData cellData) { + final offset = index * _cellSize; + cellData.foreground = _data[offset + _cellForeground]; + cellData.background = _data[offset + _cellBackground]; + cellData.flags = _data[offset + _cellAttributes]; + cellData.content = _data[offset + _cellContent]; + } + + CellData createCellData(int index) { + final cellData = CellData.empty(); + final offset = index * _cellSize; + _data[offset + _cellForeground] = cellData.foreground; + _data[offset + _cellBackground] = cellData.background; + _data[offset + _cellAttributes] = cellData.flags; + _data[offset + _cellContent] = cellData.content; + return cellData; + } + + void setForeground(int index, int value) { + _data[index * _cellSize + _cellForeground] = value; + } + + void setBackground(int index, int value) { + _data[index * _cellSize + _cellBackground] = value; + } + + void setAttributes(int index, int value) { + _data[index * _cellSize + _cellAttributes] = value; + } + + void setContent(int index, int value) { + _data[index * _cellSize + _cellContent] = value; + } + + void setCodePoint(int index, int char) { + final width = unicodeV11.wcwidth(char); + setContent(index, char | (width << CellContent.widthShift)); + } + + void setCell(int index, int char, int witdh, CursorStyle style) { + final offset = index * _cellSize; + _data[offset + _cellForeground] = style.foreground; + _data[offset + _cellBackground] = style.background; + _data[offset + _cellAttributes] = style.attrs; + _data[offset + _cellContent] = char | (witdh << CellContent.widthShift); + } + + void setCellData(int index, CellData cellData) { + final offset = index * _cellSize; + _data[offset + _cellForeground] = cellData.foreground; + _data[offset + _cellBackground] = cellData.background; + _data[offset + _cellAttributes] = cellData.flags; + _data[offset + _cellContent] = cellData.content; + } + + void eraseCell(int index, CursorStyle style) { + final offset = index * _cellSize; + _data[offset + _cellForeground] = style.foreground; + _data[offset + _cellBackground] = style.background; + _data[offset + _cellAttributes] = style.attrs; + _data[offset + _cellContent] = 0; + } + + void resetCell(int index) { + final offset = index * _cellSize; + _data[offset + _cellForeground] = 0; + _data[offset + _cellBackground] = 0; + _data[offset + _cellAttributes] = 0; + _data[offset + _cellContent] = 0; + } + + /// Erase cells whose index satisfies [start] <= index < [end]. Erased cells + /// are filled with [style]. + void eraseRange(int start, int end, CursorStyle style) { + // reset cell one to the left if start is second cell of a wide char + if (start > 0 && getWidth(start - 1) == 2) { + eraseCell(start - 1, style); + } + + // reset cell one to the right if end is second cell of a wide char + if (end < _length && getWidth(end - 1) == 2) { + eraseCell(end - 1, style); + } + + end = min(end, _length); + for (var i = start; i < end; i++) { + eraseCell(i, style); + } + } + + /// Remove [count] cells starting at [start]. Cells that are empty after the + /// removal are filled with [style]. + void removeCells(int start, int count, [CursorStyle? style]) { + assert(start >= 0 && start < _length); + assert(count >= 0 && start + count <= _length); + + style ??= CursorStyle.empty; + + if (start + count < _length) { + final moveStart = start * _cellSize; + final moveEnd = (_length - count) * _cellSize; + final moveOffset = count * _cellSize; + for (var i = moveStart; i < moveEnd; i++) { + _data[i] = _data[i + moveOffset]; + } + } + + for (var i = _length - count; i < _length; i++) { + eraseCell(i, style); + } + + if (start > 0 && getWidth(start - 1) == 2) { + eraseCell(start - 1, style); + } + + // Update anchors, remove anchors that are inside the removed range. + for (var i = 0; i < _anchors.length; i++) { + final anchor = _anchors[i]; + if (anchor.x >= start) { + if (anchor.x < start + count) { + anchor.dispose(); + } else { + anchor.reposition(anchor.x - count); + } + } + } + } + + /// Inserts [count] cells at [start]. New cells are initialized with [style]. + void insertCells(int start, int count, [CursorStyle? style]) { + style ??= CursorStyle.empty; + + if (start > 0 && getWidth(start - 1) == 2) { + eraseCell(start - 1, style); + } + + if (start + count < _length) { + final moveStart = start * _cellSize; + final moveEnd = (_length - count) * _cellSize; + final moveOffset = count * _cellSize; + for (var i = moveEnd - 1; i >= moveStart; i--) { + _data[i + moveOffset] = _data[i]; + } + } + + final end = min(start + count, _length); + for (var i = start; i < end; i++) { + eraseCell(i, style); + } + + if (getWidth(_length - 1) == 2) { + eraseCell(_length - 1, style); + } + + // Update anchors, move anchors that are after the inserted range. + for (var i = 0; i < _anchors.length; i++) { + final anchor = _anchors[i]; + if (anchor.x >= start + count) { + anchor.reposition(anchor.x + count); + + // Remove anchors that are now outside the buffer. + if (anchor.x >= _length) { + anchor.dispose(); + } + } + } + } + + void resize(int length) { + assert(length >= 0); + + if (length == _length) { + return; + } + + if (length > _length) { + final newBufferSize = _calcCapacity(length) * _cellSize; + + if (newBufferSize > _data.length) { + final newBuffer = Uint32List(newBufferSize); + newBuffer.setRange(0, _data.length, _data); + _data = newBuffer; + } + } + + _length = length; + + for (var i = 0; i < _anchors.length; i++) { + final anchor = _anchors[i]; + if (anchor.x > _length) { + anchor.reposition(_length); + } + } + } + + /// Returns the offset of the last cell that has content from the start of + /// the line. + int getTrimmedLength([int? cols]) { + final maxCols = _data.length ~/ _cellSize; + + if (cols == null || cols > maxCols) { + cols = maxCols; + } + + if (cols <= 0) { + return 0; + } + + for (var i = cols - 1; i >= 0; i--) { + var codePoint = getCodePoint(i); + + if (codePoint != 0) { + // we are at the last cell in this line that has content. + // the length of this line is the index of this cell + 1 + // the only exception is that if that last cell is wider + // than 1 then we have to add the diff + final lastCellWidth = getWidth(i); + return i + lastCellWidth; + } + } + return 0; + } + + /// Copies [len] cells from [src] starting at [srcCol] to [dstCol] at this + /// line. + void copyFrom(BufferLine src, int srcCol, int dstCol, int len) { + resize(dstCol + len); + + // data.setRange( + // dstCol * _cellSize, + // (dstCol + len) * _cellSize, + // Uint32List.sublistView(src.data, srcCol * _cellSize, len * _cellSize), + // ); + + var srcOffset = srcCol * _cellSize; + var dstOffset = dstCol * _cellSize; + + for (var i = 0; i < len * _cellSize; i++) { + _data[dstOffset++] = src._data[srcOffset++]; + } + } + + static int _calcCapacity(int length) { + assert(length >= 0); + + var capacity = 64; + + if (length < 256) { + while (capacity < length) { + capacity *= 2; + } + } else { + capacity = 256; + while (capacity < length) { + capacity += 32; + } + } + + return capacity; + } + + String getText([int? from, int? to]) { + if (from == null || from < 0) { + from = 0; + } + + if (to == null || to > _length) { + to = _length; + } + + final builder = StringBuffer(); + for (var i = from; i < to; i++) { + final codePoint = getCodePoint(i); + final width = getWidth(i); + if (codePoint != 0 && i + width <= to) { + builder.writeCharCode(codePoint); + } + } + + return builder.toString(); + } + + CellAnchor createAnchor(int offset) { + final anchor = CellAnchor(offset, owner: this); + _anchors.add(anchor); + return anchor; + } + + void dispose() { + for (final anchor in _anchors) { + anchor.dispose(); + } + } + + @override + String toString() { + return getText(); + } +} + +/// A handle to a cell in a [BufferLine] that can be used to track the location +/// of the cell. Anchors are guaranteed to be stable, retaining their relative +/// position to each other after mutations to the buffer. +class CellAnchor { + CellAnchor(int offset, {BufferLine? owner}) + : _offset = offset, + _owner = owner; + + int _offset; + + int get x { + return _offset; + } + + int get y { + assert(attached); + return _owner!.index; + } + + CellOffset get offset { + assert(attached); + return CellOffset(_offset, _owner!.index); + } + + BufferLine? _owner; + + BufferLine? get line => _owner; + + bool get attached => _owner?.attached ?? false; + + void reparent(BufferLine owner, int offset) { + _owner?._anchors.remove(this); + _owner = owner; + _owner?._anchors.add(this); + _offset = offset; + } + + void reposition(int offset) { + _offset = offset; + } + + void dispose() { + _owner?._anchors.remove(this); + _owner = null; + } + + @override + String toString() { + if (attached) { + return 'CellAnchor($x, $y)'; + } else { + return 'CellAnchor($x, detached)'; + } + } +} diff --git a/lib/src/terminal/src/core/buffer/range.dart b/lib/src/terminal/src/core/buffer/range.dart new file mode 100644 index 00000000..b50a131b --- /dev/null +++ b/lib/src/terminal/src/core/buffer/range.dart @@ -0,0 +1,57 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/segment.dart'; + +abstract class BufferRange { + final CellOffset begin; + + final CellOffset end; + + const BufferRange(this.begin, this.end); + + BufferRange.collapsed(this.begin) : end = begin; + + bool get isNormalized { + return begin.isBefore(end) || begin.isEqual(end); + } + + bool get isCollapsed { + return begin.isEqual(end); + } + + BufferRange get normalized; + + /// Convert this range to segments of single lines. + Iterable toSegments(); + + /// Returns true if the given[position] is within this range. + bool contains(CellOffset position); + + /// Returns the smallest range that contains both this range and the given + /// [range]. + BufferRange merge(BufferRange range); + + /// Returns the smallest range that contains both this range and the given + /// [position]. + BufferRange extend(CellOffset position); + + @override + operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! BufferRange) { + return false; + } + + return begin == other.begin && end == other.end; + } + + @override + int get hashCode => begin.hashCode ^ end.hashCode; + + @override + String toString() => 'Range($begin, $end)'; +} diff --git a/lib/src/terminal/src/core/buffer/range_block.dart b/lib/src/terminal/src/core/buffer/range_block.dart new file mode 100644 index 00000000..427c6fdb --- /dev/null +++ b/lib/src/terminal/src/core/buffer/range_block.dart @@ -0,0 +1,113 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math'; + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; +import 'package:clide/src/terminal/src/core/buffer/segment.dart'; + +class BufferRangeBlock extends BufferRange { + BufferRangeBlock(super.begin, super.end); + + BufferRangeBlock.collapsed(super.begin) : super.collapsed(); + + @override + bool get isNormalized { + // A block range is normalized if begin is the top left corner of the range + // and end the bottom right corner. + return (begin.isBefore(end) && begin.x <= end.x) || begin.isEqual(end); + } + + @override + BufferRangeBlock get normalized { + if (isNormalized) { + return this; + } + // Determine new normalized begin and end offset, such that begin is the + // top left corner and end is the bottom right corner of the block. + final normalBegin = CellOffset(min(begin.x, end.x), min(begin.y, end.y)); + final normalEnd = CellOffset(max(begin.x, end.x), max(begin.y, end.y)); + return BufferRangeBlock(normalBegin, normalEnd); + } + + @override + Iterable toSegments() sync* { + var begin = this.begin; + var end = this.end; + + if (!isNormalized) { + end = this.begin; + begin = this.end; + } + + final startX = min(begin.x, end.x); + final endX = max(begin.x, end.x); + for (var i = begin.y; i <= end.y; i++) { + yield BufferSegment(this, i, startX, endX); + } + } + + @override + bool contains(CellOffset position) { + var begin = this.begin; + var end = this.end; + + if (!isNormalized) { + end = this.begin; + begin = this.end; + } + if (!(begin.y <= position.y && position.y <= end.y)) { + return false; + } + + final startX = min(begin.x, end.x); + final endX = max(begin.x, end.x); + return startX <= position.x && position.x <= endX; + } + + @override + BufferRangeBlock merge(BufferRange range) { + // Enlarge the block such that both borders of the range + // are within the selected block. + return extend(range.begin).extend(range.end); + } + + @override + BufferRangeBlock extend(CellOffset position) { + // If the position is within the block, there is nothing to do. + if (contains(position)) { + return this; + } + // Otherwise normalize the block and push the borders outside up to + // the position to which the block has to extended. + final normal = normalized; + final extendBegin = CellOffset( + min(normal.begin.x, position.x), + min(normal.begin.y, position.y), + ); + final extendEnd = CellOffset( + max(normal.end.x, position.x), + max(normal.end.y, position.y), + ); + return BufferRangeBlock(extendBegin, extendEnd); + } + + @override + operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! BufferRangeBlock) { + return false; + } + + return begin == other.begin && end == other.end; + } + + @override + int get hashCode => begin.hashCode ^ end.hashCode; + + @override + String toString() => 'Block Range($begin, $end)'; +} diff --git a/lib/src/terminal/src/core/buffer/range_line.dart b/lib/src/terminal/src/core/buffer/range_line.dart new file mode 100644 index 00000000..4f05983c --- /dev/null +++ b/lib/src/terminal/src/core/buffer/range_line.dart @@ -0,0 +1,68 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; +import 'package:clide/src/terminal/src/core/buffer/segment.dart'; + +class BufferRangeLine extends BufferRange { + BufferRangeLine(super.begin, super.end); + + BufferRangeLine.collapsed(super.begin) : super.collapsed(); + + @override + BufferRangeLine get normalized { + return isNormalized ? this : BufferRangeLine(end, begin); + } + + @override + Iterable toSegments() sync* { + final self = normalized; + for (var i = self.begin.y; i <= self.end.y; i++) { + var startX = i == self.begin.y ? self.begin.x : null; + var endX = i == self.end.y ? self.end.x : null; + yield BufferSegment(this, i, startX, endX); + } + } + + @override + bool contains(CellOffset position) { + final self = normalized; + return self.begin.isBeforeOrSame(position) && + self.end.isAfterOrSame(position); + } + + @override + BufferRangeLine merge(BufferRange range) { + final self = normalized; + final begin = self.begin.isBefore(range.begin) ? self.begin : range.begin; + final end = self.end.isAfter(range.end) ? self.end : range.end; + return BufferRangeLine(begin, end); + } + + @override + BufferRangeLine extend(CellOffset position) { + final self = normalized; + final begin = self.begin.isAfter(position) ? position : self.begin; + final end = self.end.isBefore(position) ? position : self.end; + return BufferRangeLine(begin, end); + } + + @override + operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! BufferRangeLine) { + return false; + } + + return begin == other.begin && end == other.end; + } + + @override + int get hashCode => begin.hashCode ^ end.hashCode; + + @override + String toString() => 'Line Range($begin, $end)'; +} diff --git a/lib/src/terminal/src/core/buffer/segment.dart b/lib/src/terminal/src/core/buffer/segment.dart new file mode 100644 index 00000000..d8f6a799 --- /dev/null +++ b/lib/src/terminal/src/core/buffer/segment.dart @@ -0,0 +1,60 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; + +/// A BufferSegment represents a range within a line. +class BufferSegment { + /// The range that this segment belongs to. + final BufferRange range; + + /// The line that this segment resides on. + final int line; + + /// The start position of this segment. [null] means the start of the line. + final int? start; + + /// The end position of this segment. [null] means the end of the line. + /// Should be greater than or equal to [start]. + final int? end; + + const BufferSegment(this.range, this.line, this.start, this.end) + : assert((start != null && end != null) ? start <= end : true); + + bool isWithin(CellOffset position) { + if (position.y != line) { + return false; + } + + if (start != null && position.x < start!) { + return false; + } + + if (end != null && position.x > end!) { + return false; + } + + return true; + } + + @override + String toString() { + final start = this.start != null ? this.start.toString() : 'start'; + final end = this.end != null ? this.end.toString() : 'end'; + return 'Segment($line, $start -> $end)'; + } + + @override + int get hashCode => + range.hashCode ^ line.hashCode ^ start.hashCode ^ end.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BufferSegment && + runtimeType == other.runtimeType && + range == other.range && + line == other.line && + start == other.start && + end == other.end; +} diff --git a/lib/src/terminal/src/core/cell.dart b/lib/src/terminal/src/core/cell.dart new file mode 100644 index 00000000..611ca64e --- /dev/null +++ b/lib/src/terminal/src/core/cell.dart @@ -0,0 +1,68 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/utils/hash_values.dart'; + +class CellData { + CellData({ + required this.foreground, + required this.background, + required this.flags, + required this.content, + }); + + factory CellData.empty() { + return CellData( + foreground: 0, + background: 0, + flags: 0, + content: 0, + ); + } + + int foreground; + + int background; + + int flags; + + int content; + + int getHash() { + return hashValues(foreground, background, flags, content); + } + + @override + String toString() { + return 'CellData{foreground: $foreground, background: $background, flags: $flags, content: $content}'; + } +} + +abstract class CellAttr { + static const bold = 1 << 0; + static const faint = 1 << 1; + static const italic = 1 << 2; + static const underline = 1 << 3; + static const blink = 1 << 4; + static const inverse = 1 << 5; + static const invisible = 1 << 6; + static const strikethrough = 1 << 7; +} + +abstract class CellColor { + static const valueMask = 0xFFFFFF; + + static const typeShift = 25; + static const typeMask = 3 << typeShift; + + static const normal = 0 << typeShift; + static const named = 1 << typeShift; + static const palette = 2 << typeShift; + static const rgb = 3 << typeShift; +} + +abstract class CellContent { + static const codepointMask = 0x1fffff; + + static const widthShift = 22; + // static const widthMask = 3 << widthShift; +} diff --git a/lib/src/terminal/src/core/charset.dart b/lib/src/terminal/src/core/charset.dart new file mode 100644 index 00000000..9b22a168 --- /dev/null +++ b/lib/src/terminal/src/core/charset.dart @@ -0,0 +1,97 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +typedef CharsetTranslator = int Function(int); + +final _charsets = { + '0'.codeUnitAt(0): decSpecGraphicsTranslator, + 'B'.codeUnitAt(0): asciiTranslator, +}; + +class Charset { + var _charsetMap = {}; + var _currentIndex = 0; + + var _savedCharsetMap = {}; + var _savedIndex = 0; + + var _cached = asciiTranslator; + + void _updateCache() { + _cached = _charsetMap[_currentIndex] ?? asciiTranslator; + } + + int translate(int codePoint) { + return _cached(codePoint); + } + + void designate(int index, int name) { + final charset = _charsets[name]; + if (charset != null) { + _charsetMap[index] = charset; + _updateCache(); + } + } + + void use(int index) { + _currentIndex = index; + _updateCache(); + } + + void save() { + _savedCharsetMap = Map.from(_charsetMap); + _savedIndex = _currentIndex; + } + + void restore() { + _charsetMap = _savedCharsetMap; + _currentIndex = _savedIndex; + _updateCache(); + } +} + +const decSpecGraphics = { + 0x5f: 0x00A0, // NO-BREAK SPACE + 0x60: 0x25C6, // BLACK DIAMOND + 0x61: 0x2592, // MEDIUM SHADE + 0x62: 0x2409, // SYMBOL FOR HORIZONTAL TABULATION + 0x63: 0x240C, // SYMBOL FOR FORM FEED + 0x64: 0x240D, // SYMBOL FOR CARRIAGE RETURN + 0x65: 0x240A, // SYMBOL FOR LINE FEED + 0x66: 0x00B0, // DEGREE SIGN + 0x67: 0x00B1, // PLUS-MINUS SIGN + 0x68: 0x2424, // SYMBOL FOR NEWLINE + 0x69: 0x240B, // SYMBOL FOR VERTICAL TABULATION + 0x6a: 0x2518, // BOX DRAWINGS LIGHT UP AND LEFT + 0x6b: 0x2510, // BOX DRAWINGS LIGHT DOWN AND LEFT + 0x6c: 0x250C, // BOX DRAWINGS LIGHT DOWN AND RIGHT + 0x6d: 0x2514, // BOX DRAWINGS LIGHT UP AND RIGHT + 0x6e: 0x253C, // BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL + 0x6f: 0x23BA, // HORIZONTAL SCAN LINE-1 + 0x70: 0x23BB, // HORIZONTAL SCAN LINE-3 + 0x71: 0x2500, // BOX DRAWINGS LIGHT HORIZONTAL + 0x72: 0x23BC, // HORIZONTAL SCAN LINE-7 + 0x73: 0x23BD, // HORIZONTAL SCAN LINE-9 + 0x74: 0x251C, // BOX DRAWINGS LIGHT VERTICAL AND RIGHT + 0x75: 0x2524, // BOX DRAWINGS LIGHT VERTICAL AND LEFT + 0x76: 0x2534, // BOX DRAWINGS LIGHT UP AND HORIZONTAL + 0x77: 0x252C, // BOX DRAWINGS LIGHT DOWN AND HORIZONTAL + 0x78: 0x2502, // BOX DRAWINGS LIGHT VERTICAL + 0x79: 0x2264, // LESS-THAN OR EQUAL TO + 0x7a: 0x2265, // GREATER-THAN OR EQUAL TO + 0x7b: 0x03C0, // GREEK SMALL LETTER PI + 0x7c: 0x2260, // NOT EQUAL TO + 0x7d: 0x00A3, // POUND SIGN + 0x7e: 0x00B7, // MIDDLE DOT +}; + +int asciiTranslator(int codePoint) { + return codePoint; +} + +int decSpecGraphicsTranslator(int codePoint) { + if (codePoint >= 127) { + return codePoint; + } + + return decSpecGraphics[codePoint] ?? codePoint; +} diff --git a/lib/src/terminal/src/core/color.dart b/lib/src/terminal/src/core/color.dart new file mode 100644 index 00000000..c3f9fe81 --- /dev/null +++ b/lib/src/terminal/src/core/color.dart @@ -0,0 +1,21 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +abstract class NamedColor { + static const black = 0; + static const red = 1; + static const green = 2; + static const yellow = 3; + static const blue = 4; + static const magenta = 5; + static const cyan = 6; + static const white = 7; + + static const brightBlack = 8; + static const brightRed = 9; + static const brightGreen = 10; + static const brightYellow = 11; + static const brightBlue = 12; + static const brightMagenta = 13; + static const brightCyan = 14; + static const brightWhite = 15; +} diff --git a/lib/src/terminal/src/core/cursor.dart b/lib/src/terminal/src/core/cursor.dart new file mode 100644 index 00000000..e06c980b --- /dev/null +++ b/lib/src/terminal/src/core/cursor.dart @@ -0,0 +1,139 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/cell.dart'; + +class CursorStyle { + int foreground; + + int background; + + int attrs; + + CursorStyle({this.foreground = 0, this.background = 0, this.attrs = 0}); + + static final empty = CursorStyle(); + + void setBold() { + attrs |= CellAttr.bold; + } + + void setFaint() { + attrs |= CellAttr.faint; + } + + void setItalic() { + attrs |= CellAttr.italic; + } + + void setUnderline() { + attrs |= CellAttr.underline; + } + + void setBlink() { + attrs |= CellAttr.blink; + } + + void setInverse() { + attrs |= CellAttr.inverse; + } + + void setInvisible() { + attrs |= CellAttr.invisible; + } + + void setStrikethrough() { + attrs |= CellAttr.strikethrough; + } + + void unsetBold() { + attrs &= ~CellAttr.bold; + } + + void unsetFaint() { + attrs &= ~CellAttr.faint; + } + + void unsetItalic() { + attrs &= ~CellAttr.italic; + } + + void unsetUnderline() { + attrs &= ~CellAttr.underline; + } + + void unsetBlink() { + attrs &= ~CellAttr.blink; + } + + void unsetInverse() { + attrs &= ~CellAttr.inverse; + } + + void unsetInvisible() { + attrs &= ~CellAttr.invisible; + } + + void unsetStrikethrough() { + attrs &= ~CellAttr.strikethrough; + } + + bool get isBold => (attrs & CellAttr.bold) != 0; + + bool get isFaint => (attrs & CellAttr.faint) != 0; + + bool get isItalis => (attrs & CellAttr.italic) != 0; + + bool get isUnderline => (attrs & CellAttr.underline) != 0; + + bool get isBlink => (attrs & CellAttr.blink) != 0; + + bool get isInverse => (attrs & CellAttr.inverse) != 0; + + bool get isInvisible => (attrs & CellAttr.invisible) != 0; + + void setForegroundColor16(int color) { + foreground = color | CellColor.named; + } + + void setForegroundColor256(int color) { + foreground = color | CellColor.palette; + } + + void setForegroundColorRgb(int r, int g, int b) { + foreground = (r << 16) | (g << 8) | b | CellColor.rgb; + } + + void resetForegroundColor() { + foreground = 0; // | CellColor.normal; + } + + void setBackgroundColor16(int color) { + background = color | CellColor.named; + } + + void setBackgroundColor256(int color) { + background = color | CellColor.palette; + } + + void setBackgroundColorRgb(int r, int g, int b) { + background = (r << 16) | (g << 8) | b | CellColor.rgb; + } + + void resetBackgroundColor() { + background = 0; // | CellColor.normal; + } + + void reset() { + foreground = 0; + background = 0; + attrs = 0; + } +} + +class CursorPosition { + int x; + + int y; + + CursorPosition(this.x, this.y); +} diff --git a/lib/src/terminal/src/core/escape/emitter.dart b/lib/src/terminal/src/core/escape/emitter.dart new file mode 100644 index 00000000..78439928 --- /dev/null +++ b/lib/src/terminal/src/core/escape/emitter.dart @@ -0,0 +1,35 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +class EscapeEmitter { + const EscapeEmitter(); + + String primaryDeviceAttributes() { + return '\x1b[?1;2c'; + } + + String secondaryDeviceAttributes() { + const model = 0; + const version = 0; + return '\x1b[>$model;$version;0c'; + } + + String tertiaryDeviceAttributes() { + return '\x1bP!|00000000\x1b\\'; + } + + String operatingStatus() { + return '\x1b[0n'; + } + + String cursorPosition(int x, int y) { + return '\x1b[$y;${x}R'; + } + + String bracketedPaste(String text) { + return '\x1b[200~$text\x1b[201~'; + } + + String size(int rows, int cols) { + return '\x1b[8;$rows;${cols}t'; + } +} diff --git a/lib/src/terminal/src/core/escape/handler.dart b/lib/src/terminal/src/core/escape/handler.dart new file mode 100644 index 00000000..03822018 --- /dev/null +++ b/lib/src/terminal/src/core/escape/handler.dart @@ -0,0 +1,217 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; + +abstract class EscapeHandler { + void writeChar(int char); + + /* SBC */ + + void bell(); + + void backspaceReturn(); + + void tab(); + + void lineFeed(); + + void carriageReturn(); + + void shiftOut(); + + void shiftIn(); + + void unknownSBC(int char); + + /* ANSI sequence */ + + void saveCursor(); + + void restoreCursor(); + + void index(); + + void nextLine(); + + void setTapStop(); + + void reverseIndex(); + + void designateCharset(int charset, int name); + + void unkownEscape(int char); + + /* CSI */ + + void repeatPreviousCharacter(int n); + + void setCursor(int x, int y); + + void setCursorX(int x); + + void setCursorY(int y); + + void sendPrimaryDeviceAttributes(); + + void clearTabStopUnderCursor(); + + void clearAllTabStops(); + + void moveCursorX(int offset); + + void moveCursorY(int n); + + void sendSecondaryDeviceAttributes(); + + void sendTertiaryDeviceAttributes(); + + void sendOperatingStatus(); + + void sendCursorPosition(); + + void setMargins(int i, [int? bottom]); + + void cursorNextLine(int amount); + + void cursorPrecedingLine(int amount); + + void eraseDisplayBelow(); + + void eraseDisplayAbove(); + + void eraseDisplay(); + + void eraseScrollbackOnly(); + + void eraseLineRight(); + + void eraseLineLeft(); + + void eraseLine(); + + void insertLines(int amount); + + void deleteLines(int amount); + + void deleteChars(int amount); + + void scrollUp(int amount); + + void scrollDown(int amount); + + void eraseChars(int amount); + + void insertBlankChars(int amount); + + void unknownCSI(int finalByte); + + /* Modes */ + + void setInsertMode(bool enabled); + + void setLineFeedMode(bool enabled); + + void setUnknownMode(int mode, bool enabled); + + /* DEC Private modes */ + + void setCursorKeysMode(bool enabled); + + void setReverseDisplayMode(bool enabled); + + void setOriginMode(bool enabled); + + void setColumnMode(bool enabled); + + void setAutoWrapMode(bool enabled); + + void setMouseMode(MouseMode mode); + + void setCursorBlinkMode(bool enabled); + + void setCursorVisibleMode(bool enabled); + + void useAltBuffer(); + + void useMainBuffer(); + + void clearAltBuffer(); + + void setAppKeypadMode(bool enabled); + + void setReportFocusMode(bool enabled); + + void setMouseReportMode(MouseReportMode mode); + + void setAltBufferMouseScrollMode(bool enabled); + + void setBracketedPasteMode(bool enabled); + + void setUnknownDecMode(int mode, bool enabled); + + void resize(int cols, int rows); + + void sendSize(); + + /* Select Graphic Rendition (SGR) */ + + void resetCursorStyle(); + + void setCursorBold(); + + void setCursorFaint(); + + void setCursorItalic(); + + void setCursorUnderline(); + + void setCursorBlink(); + + void setCursorInverse(); + + void setCursorInvisible(); + + void setCursorStrikethrough(); + + void unsetCursorBold(); + + void unsetCursorFaint(); + + void unsetCursorItalic(); + + void unsetCursorUnderline(); + + void unsetCursorBlink(); + + void unsetCursorInverse(); + + void unsetCursorInvisible(); + + void unsetCursorStrikethrough(); + + void setForegroundColor16(int color); + + void setForegroundColor256(int index); + + void setForegroundColorRgb(int r, int g, int b); + + void resetForeground(); + + void setBackgroundColor16(int color); + + void setBackgroundColor256(int index); + + void setBackgroundColorRgb(int r, int g, int b); + + void resetBackground(); + + void unsupportedStyle(int param); + + /* OSC */ + + void setTitle(String name); + + void setIconName(String name); + + void unknownOSC(String code, List args); +} diff --git a/lib/src/terminal/src/core/escape/parser.dart b/lib/src/terminal/src/core/escape/parser.dart new file mode 100644 index 00000000..44fb9ea9 --- /dev/null +++ b/lib/src/terminal/src/core/escape/parser.dart @@ -0,0 +1,1156 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/color.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; +import 'package:clide/src/terminal/src/core/escape/handler.dart'; +import 'package:clide/src/terminal/src/utils/ascii.dart'; +import 'package:clide/src/terminal/src/utils/byte_consumer.dart'; +import 'package:clide/src/terminal/src/utils/char_code.dart'; +import 'package:clide/src/terminal/src/utils/lookup_table.dart'; + +/// [EscapeParser] translates control characters and escape sequences into +/// function calls that the terminal can handle. +/// +/// Design goals: +/// * Zero object allocation during processing. +/// * No internal state. Same input will always produce same output. +class EscapeParser { + final EscapeHandler handler; + + EscapeParser(this.handler); + + final _queue = ByteConsumer(); + + /// Start of sequence or character being processed. Useful for debugging. + var tokenBegin = 0; + + /// End of sequence or character being processed. Useful for debugging. + int get tokenEnd => _queue.totalConsumed; + + void write(String chunk) { + _queue.unrefConsumedBlocks(); + _queue.add(chunk); + _process(); + } + + void _process() { + while (_queue.isNotEmpty) { + tokenBegin = _queue.totalConsumed; + final char = _queue.consume(); + + if (char == Ascii.ESC) { + final processed = _processEscape(); + if (!processed) { + _queue.rollback(tokenEnd - tokenBegin); + return; + } + } else { + _processChar(char); + } + } + } + + void _processChar(int char) { + if (char > _sbcHandlers.maxIndex) { + handler.writeChar(char); + return; + } + + final sbcHandler = _sbcHandlers[char]; + if (sbcHandler == null) { + handler.unkownEscape(char); + return; + } + + sbcHandler(); + } + + /// Processes a sequence of characters that starts with an escape character. + /// Returns [true] if the sequence was processed, [false] if it was not. + bool _processEscape() { + if (_queue.isEmpty) return false; + + final escapeChar = _queue.consume(); + final escapeHandler = _escHandlers[escapeChar]; + + if (escapeHandler == null) { + handler.unkownEscape(escapeChar); + return true; + } + + return escapeHandler(); + } + + late final _sbcHandlers = FastLookupTable<_SbcHandler>({ + 0x07: handler.bell, + 0x08: handler.backspaceReturn, + 0x09: handler.tab, + 0x0a: handler.lineFeed, + 0x0b: handler.lineFeed, + 0x0c: handler.lineFeed, + 0x0d: handler.carriageReturn, + 0x0e: handler.shiftOut, + 0x0f: handler.shiftIn, + }); + + late final _escHandlers = FastLookupTable<_EscHandler>({ + '['.charCode: _escHandleCSI, + ']'.charCode: _escHandleOSC, + '7'.charCode: _escHandleSaveCursor, + '8'.charCode: _escHandleRestoreCursor, + 'D'.charCode: _escHandleIndex, + 'E'.charCode: _escHandleNextLine, + 'H'.charCode: _escHandleTabSet, + 'M'.charCode: _escHandleReverseIndex, + // 'P'.charCode: _unsupportedHandler, // Sixel + // 'c'.charCode: _unsupportedHandler, + // '#'.charCode: _unsupportedHandler, + '('.charCode: _escHandleDesignateCharset0, // SCS - G0 + ')'.charCode: _escHandleDesignateCharset1, // SCS - G1 + // '*'.charCode: _voidHandler(1), // TODO: G2 (vt220) + // '+'.charCode: _voidHandler(1), // TODO: G3 (vt220) + '>'.charCode: _escHandleResetAppKeypadMode, // TODO: Normal Keypad + '='.charCode: _escHandleSetAppKeypadMode, // TODO: Application Keypad + }); + + /// `ESC 7` Save Cursor (DECSC) + /// + /// https://terminalguide.namepad.de/seq/a_esc_a7/ + bool _escHandleSaveCursor() { + handler.saveCursor(); + return true; + } + + /// `ESC 8` Restore Cursor (DECRC) + /// + /// https://terminalguide.namepad.de/seq/a_esc_a8/ + bool _escHandleRestoreCursor() { + handler.restoreCursor(); + return true; + } + + /// `ESC D` Index (IND) + /// + /// https://terminalguide.namepad.de/seq/a_esc_cd/ + bool _escHandleIndex() { + handler.index(); + return true; + } + + /// `ESC E` Next Line (NEL) + /// + /// https://terminalguide.namepad.de/seq/a_esc_ce/ + bool _escHandleNextLine() { + handler.nextLine(); + return true; + } + + /// `ESC H` Horizontal Tab Set (HTS) + /// + /// https://terminalguide.namepad.de/seq/a_esc_ch/ + bool _escHandleTabSet() { + handler.setTapStop(); + return true; + } + + /// `ESC M` Reverse Index (RI) + /// + /// https://terminalguide.namepad.de/seq/a_esc_cm/ + bool _escHandleReverseIndex() { + handler.reverseIndex(); + return true; + } + + bool _escHandleDesignateCharset0() { + if (_queue.isEmpty) return false; + int name = _queue.consume(); + handler.designateCharset(0, name); + return true; + } + + bool _escHandleDesignateCharset1() { + if (_queue.isEmpty) return false; + int name = _queue.consume(); + handler.designateCharset(1, name); + return true; + } + + /// `ESC >` Reset Application Keypad Mode (DECKPNM) + /// + /// https://terminalguide.namepad.de/seq/a_esc_x3c_greater_than/ + bool _escHandleSetAppKeypadMode() { + handler.setAppKeypadMode(true); + return true; + } + + /// `ESC =` Set Application Keypad Mode (DECKPAM) + /// + /// https://terminalguide.namepad.de/seq/a_esc_x3d_equals/ + bool _escHandleResetAppKeypadMode() { + handler.setAppKeypadMode(false); + return true; + } + + bool _escHandleCSI() { + final consumed = _consumeCsi(); + if (!consumed) return false; + + final csiHandler = _csiHandlers[_csi.finalByte]; + + if (csiHandler == null) { + handler.unknownCSI(_csi.finalByte); + } else { + csiHandler(); + } + + return true; + } + + /// The last parsed [_Csi]. This is a mutable singletion by design to reduce + /// object allocations. + final _csi = _Csi(finalByte: 0, params: []); + + /// Parse a CSI from the head of the queue. Return false if the CSI isn't + /// complete. After a CSI is successfully parsed, [_csi] is updated. + bool _consumeCsi() { + if (_queue.isEmpty) { + return false; + } + + _csi.params.clear(); + + // test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...` + final prefix = _queue.peek(); + if (prefix >= Ascii.colon && prefix <= Ascii.questionMark) { + _csi.prefix = prefix; + _queue.consume(); + } else { + _csi.prefix = null; + } + + var param = 0; + var hasParam = false; + while (true) { + // The sequence isn't completed, just ignore it. + if (_queue.isEmpty) { + return false; + } + + final char = _queue.consume(); + + if (char == Ascii.semicolon) { + if (hasParam) { + _csi.params.add(param); + } + param = 0; + continue; + } + + if (char >= Ascii.num0 && char <= Ascii.num9) { + hasParam = true; + param *= 10; + param += char - Ascii.num0; + continue; + } + + if (char > Ascii.NULL && char < Ascii.num0) { + // intermediates.add(char); + continue; + } + + if (char >= Ascii.atSign && char <= Ascii.tilde) { + if (hasParam) { + _csi.params.add(param); + } + + _csi.finalByte = char; + return true; + } + } + } + + late final _csiHandlers = FastLookupTable<_CsiHandler>({ + // 'a'.codeUnitAt(0): _csiHandleCursorHorizontalRelative, + 'b'.codeUnitAt(0): _csiHandleRepeatPreviousCharacter, + 'c'.codeUnitAt(0): _csiHandleSendDeviceAttributes, + 'd'.codeUnitAt(0): _csiHandleLinePositionAbsolute, + 'f'.codeUnitAt(0): _csiHandleCursorPosition, + 'g'.codeUnitAt(0): _csiHandelClearTabStop, + 'h'.codeUnitAt(0): _csiHandleMode, + 'l'.codeUnitAt(0): _csiHandleMode, + 'm'.codeUnitAt(0): _csiHandleSgr, + 'n'.codeUnitAt(0): _csiHandleDeviceStatusReport, + 'r'.codeUnitAt(0): _csiHandleSetMargins, + 't'.codeUnitAt(0): _csiWindowManipulation, + 'A'.codeUnitAt(0): _csiHandleCursorUp, + 'B'.codeUnitAt(0): _csiHandleCursorDown, + 'C'.codeUnitAt(0): _csiHandleCursorForward, + 'D'.codeUnitAt(0): _csiHandleCursorBackward, + 'E'.codeUnitAt(0): _csiHandleCursorNextLine, + 'F'.codeUnitAt(0): _csiHandleCursorPrecedingLine, + 'G'.codeUnitAt(0): _csiHandleCursorHorizontalAbsolute, + 'H'.codeUnitAt(0): _csiHandleCursorPosition, + 'J'.codeUnitAt(0): _csiHandleEraseDisplay, + 'K'.codeUnitAt(0): _csiHandleEraseLine, + 'L'.codeUnitAt(0): _csiHandleInsertLines, + 'M'.codeUnitAt(0): _csiHandleDeleteLines, + 'P'.codeUnitAt(0): _csiHandleDelete, + 'S'.codeUnitAt(0): _csiHandleScrollUp, + 'T'.codeUnitAt(0): _csiHandleScrollDown, + 'X'.codeUnitAt(0): _csiHandleEraseCharacters, + '@'.codeUnitAt(0): _csiHandleInsertBlankCharacters, + }); + + /// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR) + /// + /// https://terminalguide.namepad.de/seq/csi_sa/ + // void _csiHandleCursorHorizontalRelative() { + // if (_csi.params.isEmpty) { + // handler.cursorHorizontal(1); + // } else { + // handler.cursorHorizontal(_csi.params[0]); + // } + // } + + /// `ESC [ Ps b` Repeat Previous Character (REP) + /// + /// https://terminalguide.namepad.de/seq/csi_sb/ + void _csiHandleRepeatPreviousCharacter() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.repeatPreviousCharacter(amount); + } + + /// `ESC [ Ps c` Device Attributes (DA) + /// + /// https://terminalguide.namepad.de/seq/csi_sc/ + void _csiHandleSendDeviceAttributes() { + switch (_csi.prefix) { + case Ascii.greaterThan: + return handler.sendSecondaryDeviceAttributes(); + case Ascii.equal: + return handler.sendTertiaryDeviceAttributes(); + default: + handler.sendPrimaryDeviceAttributes(); + } + } + + /// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA) + /// + /// https://terminalguide.namepad.de/seq/csi_sd/ + void _csiHandleLinePositionAbsolute() { + var y = 1; + + if (_csi.params.isNotEmpty) { + y = _csi.params[0]; + } + + handler.setCursorY(y - 1); + } + + /// `ESC [ Ps ; Ps f` Alias: Set Cursor Position + /// + /// https://terminalguide.namepad.de/seq/csi_sf/ + void _csiHandleCursorPosition() { + var row = 1; + var col = 1; + + if (_csi.params.length == 2) { + row = _csi.params[0]; + col = _csi.params[1]; + } + + handler.setCursor(col - 1, row - 1); + } + + /// `ESC [ Ps g` Tab Clear (TBC) + /// + /// https://terminalguide.namepad.de/seq/csi_sg/ + void _csiHandelClearTabStop() { + var cmd = 0; + + if (_csi.params.length == 1) { + cmd = _csi.params[0]; + } + + switch (cmd) { + case 0: + return handler.clearTabStopUnderCursor(); + default: + return handler.clearAllTabStops(); + } + } + + /// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/ + /// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/ + /// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/ + /// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/ + void _csiHandleMode() { + final isEnabled = _csi.finalByte == Ascii.h; + + final isDecModes = _csi.prefix == Ascii.questionMark; + + if (isDecModes) { + for (var mode in _csi.params) { + _setDecMode(mode, isEnabled); + } + } else { + for (var mode in _csi.params) { + _setMode(mode, isEnabled); + } + } + } + + /// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR) + /// + /// https://terminalguide.namepad.de/seq/csi_sm/ + void _csiHandleSgr() { + final params = _csi.params; + + if (params.isEmpty) { + return handler.resetCursorStyle(); + } + + // This is a workaround for a bug in the analyzer. + // ignore: dead_code + for (var i = 0; i < _csi.params.length; i++) { + final param = params[i]; + switch (param) { + case 0: + handler.resetCursorStyle(); + continue; + case 1: + handler.setCursorBold(); + continue; + case 2: + handler.setCursorFaint(); + continue; + case 3: + handler.setCursorItalic(); + continue; + case 4: + handler.setCursorUnderline(); + continue; + case 5: + handler.setCursorBlink(); + continue; + case 7: + handler.setCursorInverse(); + continue; + case 8: + handler.setCursorInvisible(); + continue; + case 9: + handler.setCursorStrikethrough(); + continue; + + case 21: + handler.unsetCursorBold(); + continue; + case 22: + handler.unsetCursorFaint(); + continue; + case 23: + handler.unsetCursorItalic(); + continue; + case 24: + handler.unsetCursorUnderline(); + continue; + case 25: + handler.unsetCursorBlink(); + continue; + case 27: + handler.unsetCursorInverse(); + continue; + case 28: + handler.unsetCursorInvisible(); + continue; + case 29: + handler.unsetCursorStrikethrough(); + continue; + + case 30: + handler.setForegroundColor16(NamedColor.black); + continue; + case 31: + handler.setForegroundColor16(NamedColor.red); + continue; + case 32: + handler.setForegroundColor16(NamedColor.green); + continue; + case 33: + handler.setForegroundColor16(NamedColor.yellow); + continue; + case 34: + handler.setForegroundColor16(NamedColor.blue); + continue; + case 35: + handler.setForegroundColor16(NamedColor.magenta); + continue; + case 36: + handler.setForegroundColor16(NamedColor.cyan); + continue; + case 37: + handler.setForegroundColor16(NamedColor.white); + continue; + case 38: + final mode = params[i + 1]; + switch (mode) { + case 2: + final r = params[i + 2]; + final g = params[i + 3]; + final b = params[i + 4]; + handler.setForegroundColorRgb(r, g, b); + i += 4; + break; + case 5: + final index = params[i + 2]; + handler.setForegroundColor256(index); + i += 2; + break; + } + continue; + case 39: + handler.resetForeground(); + continue; + + case 40: + handler.setBackgroundColor16(NamedColor.black); + continue; + case 41: + handler.setBackgroundColor16(NamedColor.red); + continue; + case 42: + handler.setBackgroundColor16(NamedColor.green); + continue; + case 43: + handler.setBackgroundColor16(NamedColor.yellow); + continue; + case 44: + handler.setBackgroundColor16(NamedColor.blue); + continue; + case 45: + handler.setBackgroundColor16(NamedColor.magenta); + continue; + case 46: + handler.setBackgroundColor16(NamedColor.cyan); + continue; + case 47: + handler.setBackgroundColor16(NamedColor.white); + continue; + case 48: + final mode = params[i + 1]; + switch (mode) { + case 2: + final r = params[i + 2]; + final g = params[i + 3]; + final b = params[i + 4]; + handler.setBackgroundColorRgb(r, g, b); + i += 4; + break; + case 5: + final index = params[i + 2]; + handler.setBackgroundColor256(index); + i += 2; + break; + } + continue; + case 49: + handler.resetBackground(); + continue; + + case 90: + handler.setForegroundColor16(NamedColor.brightBlack); + continue; + case 91: + handler.setForegroundColor16(NamedColor.brightRed); + continue; + case 92: + handler.setForegroundColor16(NamedColor.brightGreen); + continue; + case 93: + handler.setForegroundColor16(NamedColor.brightYellow); + continue; + case 94: + handler.setForegroundColor16(NamedColor.brightBlue); + continue; + case 95: + handler.setForegroundColor16(NamedColor.brightMagenta); + continue; + case 96: + handler.setForegroundColor16(NamedColor.brightCyan); + continue; + case 97: + handler.setForegroundColor16(NamedColor.brightWhite); + continue; + + case 100: + handler.setBackgroundColor16(NamedColor.brightBlack); + continue; + case 101: + handler.setBackgroundColor16(NamedColor.brightRed); + continue; + case 102: + handler.setBackgroundColor16(NamedColor.brightGreen); + continue; + case 103: + handler.setBackgroundColor16(NamedColor.brightYellow); + continue; + case 104: + handler.setBackgroundColor16(NamedColor.brightBlue); + continue; + case 105: + handler.setBackgroundColor16(NamedColor.brightMagenta); + continue; + case 106: + handler.setBackgroundColor16(NamedColor.brightCyan); + continue; + case 107: + handler.setBackgroundColor16(NamedColor.brightWhite); + continue; + + default: + handler.unsupportedStyle(param); + continue; + } + } + } + + /// `ESC [ Ps n` Device Status Report [Dispatch] (DSR) + /// + /// https://terminalguide.namepad.de/seq/csi_sn/ + void _csiHandleDeviceStatusReport() { + if (_csi.params.isEmpty) return; + + switch (_csi.params[0]) { + case 5: + return handler.sendOperatingStatus(); + case 6: + return handler.sendCursorPosition(); + } + } + + /// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM) + /// + /// https://terminalguide.namepad.de/seq/csi_sr/ + void _csiHandleSetMargins() { + var top = 1; + int? bottom; + + if (_csi.params.length > 2) return; + + if (_csi.params.isNotEmpty) { + top = _csi.params[0]; + + if (_csi.params.length == 2) { + bottom = _csi.params[1] - 1; + } + } + + handler.setMargins(top - 1, bottom); + } + + /// `ESC [ Ps t` Window operations [DISPATCH] + /// + /// https://terminalguide.namepad.de/seq/csi_st/ + void _csiWindowManipulation() { + // The sequence needs at least one parameter. + if (_csi.params.isEmpty) { + return; + } + // Most the commands in this group are either of the scope of this package, + // or should be disabled for security risks. + switch (_csi.params.first) { + // Window handling is currently not in the scope of the package. + case 1: // Restore Terminal Window (show window if minimized) + case 2: // Minimize Terminal Window + case 3: // Set Terminal Window Position + case 4: // Set Terminal Window Size in Pixels + case 5: // Raise Terminal Window + case 6: // Lower Terminal Window + case 7: // Refresh/Redraw Terminal Window + return; + case 8: // Set Terminal Window Size (in characters) + // This CSI contains 2 more parameters: width and height. + if (_csi.params.length != 3) { + return; + } + final rows = _csi.params[1]; + final cols = _csi.params[2]; + handler.resize(cols, rows); + return; + // Window handling is currently no in the scope of the package. + case 9: // Maximize Terminal Window + case 10: // Alias: Maximize Terminal Window + case 11: // Report Terminal Window State + case 13: // Report Terminal Window Position + case 14: // Report Terminal Window Size in Pixels + case 15: // Report Screen Size in Pixels + case 16: // Report Cell Size in Pixels + return; + case 18: // Report Terminal Size (in characters) + handler.sendSize(); + return; + // Screen handling is currently no in the scope of the package. + case 19: // Report Screen Size (in characters) + // Disabled as these can a security risk. + case 20: // Get Icon Title + case 21: // Get Terminal Title + // Not implemented. + case 22: // Push Terminal Title + case 23: // Pop Terminal Title + return; + // Unknown CSI. + default: + return; + } + } + + /// `ESC [ Ps A` Cursor Up (CUU) + /// + /// https://terminalguide.namepad.de/seq/csi_ca/ + void _csiHandleCursorUp() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.moveCursorY(-amount); + } + + /// `ESC [ Ps B` Cursor Down (CUD) + /// + /// https://terminalguide.namepad.de/seq/csi_cb/ + void _csiHandleCursorDown() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.moveCursorY(amount); + } + + /// `ESC [ Ps C` Cursor Right (CUF) + /// + /// Cursor Right (CUF) + void _csiHandleCursorForward() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.moveCursorX(amount); + } + + /// `ESC [ Ps D` Cursor Left (CUB) + /// + /// https://terminalguide.namepad.de/seq/csi_cd/ + void _csiHandleCursorBackward() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.moveCursorX(-amount); + } + + /// `ESC [ Ps E` Cursor Next Line (CNL) + /// + /// https://terminalguide.namepad.de/seq/csi_ce/ + void _csiHandleCursorNextLine() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.cursorNextLine(amount); + } + + /// `ESC [ Ps F` Cursor Previous Line (CPL) + /// + /// https://terminalguide.namepad.de/seq/csi_cf/ + void _csiHandleCursorPrecedingLine() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + + handler.cursorPrecedingLine(amount); + } + + void _csiHandleCursorHorizontalAbsolute() { + var x = 1; + + if (_csi.params.isNotEmpty) { + x = _csi.params[0]; + if (x == 0) x = 1; + } + + handler.setCursorX(x - 1); + } + + /// ESC [ Ps J Erase Display [Dispatch] (ED) + /// + /// https://terminalguide.namepad.de/seq/csi_cj/ + void _csiHandleEraseDisplay() { + var cmd = 0; + + if (_csi.params.length == 1) { + cmd = _csi.params[0]; + } + + switch (cmd) { + case 0: + return handler.eraseDisplayBelow(); + case 1: + return handler.eraseDisplayAbove(); + case 2: + return handler.eraseDisplay(); + case 3: + return handler.eraseScrollbackOnly(); + } + } + + /// `ESC [ Ps K` Erase Line [Dispatch] (EL) + /// + /// https://terminalguide.namepad.de/seq/csi_ck/ + void _csiHandleEraseLine() { + var cmd = 0; + + if (_csi.params.length == 1) { + cmd = _csi.params[0]; + } + + switch (cmd) { + case 0: + return handler.eraseLineRight(); + case 1: + return handler.eraseLineLeft(); + case 2: + return handler.eraseLine(); + } + } + + /// `ESC [ Ps L` Insert Line (IL) + /// + /// https://terminalguide.namepad.de/seq/csi_cl/ + void _csiHandleInsertLines() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.insertLines(amount); + } + + /// ESC [ Ps M Delete Line (DL) + /// + /// https://terminalguide.namepad.de/seq/csi_cm/ + void _csiHandleDeleteLines() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.deleteLines(amount); + } + + /// ESC [ Ps P Delete Character (DCH) + /// + /// https://terminalguide.namepad.de/seq/csi_cp/ + void _csiHandleDelete() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.deleteChars(amount); + } + + /// `ESC [ Ps S` Scroll Up (SU) + /// + /// https://terminalguide.namepad.de/seq/csi_cs/ + void _csiHandleScrollUp() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.scrollUp(amount); + } + + /// `ESC [ Ps T `Scroll Down (SD) + /// + /// https://terminalguide.namepad.de/seq/csi_ct_1param/ + void _csiHandleScrollDown() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.scrollDown(amount); + } + + /// `ESC [ Ps X` Erase Character (ECH) + /// + /// https://terminalguide.namepad.de/seq/csi_cx/ + void _csiHandleEraseCharacters() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.eraseChars(amount); + } + + /// `ESC [ Ps @` Insert Blanks (ICH) + /// + /// https://terminalguide.namepad.de/seq/csi_x40_at/ + /// + /// Inserts amount spaces at current cursor position moving existing cell + /// contents to the right. The contents of the amount right-most columns in + /// the scroll region are lost. The cursor position is not changed. + void _csiHandleInsertBlankCharacters() { + var amount = 1; + + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + } + + handler.insertBlankChars(amount); + } + + void _setMode(int mode, bool enabled) { + switch (mode) { + case 4: + return handler.setInsertMode(enabled); + case 20: + return handler.setLineFeedMode(enabled); + default: + return handler.setUnknownMode(mode, enabled); + } + } + + void _setDecMode(int mode, bool enabled) { + switch (mode) { + case 1: + return handler.setCursorKeysMode(enabled); + case 3: + return handler.setColumnMode(enabled); + case 5: + return handler.setReverseDisplayMode(enabled); + case 6: + return handler.setOriginMode(enabled); + case 7: + return handler.setAutoWrapMode(enabled); + case 9: + return enabled + ? handler.setMouseMode(MouseMode.clickOnly) + : handler.setMouseMode(MouseMode.none); + case 12: + case 13: + return handler.setCursorBlinkMode(enabled); + case 25: + return handler.setCursorVisibleMode(enabled); + case 47: + if (enabled) { + return handler.useAltBuffer(); + } else { + return handler.useMainBuffer(); + } + case 66: + return handler.setAppKeypadMode(enabled); + case 1000: + case 10061000: + return enabled + ? handler.setMouseMode(MouseMode.upDownScroll) + : handler.setMouseMode(MouseMode.none); + case 1001: + return enabled + ? handler.setMouseMode(MouseMode.upDownScroll) + : handler.setMouseMode(MouseMode.none); + case 1002: + return enabled + ? handler.setMouseMode(MouseMode.upDownScrollDrag) + : handler.setMouseMode(MouseMode.none); + case 1003: + return enabled + ? handler.setMouseMode(MouseMode.upDownScrollMove) + : handler.setMouseMode(MouseMode.none); + case 1004: + return handler.setReportFocusMode(enabled); + case 1005: + return enabled + ? handler.setMouseReportMode(MouseReportMode.utf) + : handler.setMouseReportMode(MouseReportMode.normal); + case 1006: + return enabled + ? handler.setMouseReportMode(MouseReportMode.sgr) + : handler.setMouseReportMode(MouseReportMode.normal); + case 1007: + return handler.setAltBufferMouseScrollMode(enabled); + case 1015: + return enabled + ? handler.setMouseReportMode(MouseReportMode.urxvt) + : handler.setMouseReportMode(MouseReportMode.normal); + case 1047: + if (enabled) { + handler.useAltBuffer(); + } else { + handler.clearAltBuffer(); + handler.useMainBuffer(); + } + return; + case 1048: + if (enabled) { + return handler.saveCursor(); + } else { + return handler.restoreCursor(); + } + case 1049: + if (enabled) { + handler.saveCursor(); + handler.clearAltBuffer(); + handler.useAltBuffer(); + } else { + handler.useMainBuffer(); + } + return; + case 2004: + return handler.setBracketedPasteMode(enabled); + default: + return handler.setUnknownDecMode(mode, enabled); + } + } + + /// Parse a OSC sequence from the queue. Returns true if a sequence was + /// found and handled. + bool _escHandleOSC() { + final consumed = _consumeOsc(); + if (!consumed) { + return false; + } + + if (_osc.isEmpty) { + return true; + } + + // Common OSCs + if (_osc.length >= 2) { + final ps = _osc[0]; + final pt = _osc[1]; + + switch (ps) { + case '0': + handler.setTitle(pt); + handler.setIconName(pt); + return true; + case '1': + handler.setIconName(pt); + return true; + case '2': + handler.setTitle(pt); + return true; + } + } + + // Private extensions + handler.unknownOSC(_osc[0], _osc.sublist(1)); + + return true; + } + + final _osc = []; + + bool _consumeOsc() { + _osc.clear(); + final param = StringBuffer(); + + while (true) { + if (_queue.isEmpty) { + return false; + } + + final char = _queue.consume(); + + // OSC terminates with BEL + if (char == Ascii.BEL) { + _osc.add(param.toString()); + return true; + } + + /// OSC terminates with ST + if (char == Ascii.ESC) { + if (_queue.isEmpty) { + return false; + } + + if (_queue.consume() == Ascii.backslash) { + _osc.add(param.toString()); + } + + return true; + } + + /// Parse next parameter + if (char == Ascii.semicolon) { + _osc.add(param.toString()); + param.clear(); + continue; + } + + param.writeCharCode(char); + } + } +} + +class _Csi { + _Csi({ + required this.params, + required this.finalByte, + // required this.intermediates, + }); + + int? prefix; + + List params; + + int finalByte; + // final List intermediates; + + @override + String toString() { + return params.join(';') + String.fromCharCode(finalByte); + } +} + +/// Function that handles a sequence of characters that starts with an escape. +/// Returns [true] if the sequence was processed, [false] if it was not. +typedef _EscHandler = bool Function(); + +typedef _SbcHandler = void Function(); + +typedef _CsiHandler = void Function(); diff --git a/lib/src/terminal/src/core/input/handler.dart b/lib/src/terminal/src/core/input/handler.dart new file mode 100644 index 00000000..660fb99b --- /dev/null +++ b/lib/src/terminal/src/core/input/handler.dart @@ -0,0 +1,212 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab.dart'; +import 'package:clide/src/terminal/src/core/state.dart'; +import 'package:clide/src/terminal/src/core/platform.dart'; + +/// The key event received from the keyboard, along with the state of the +/// modifier keys and state of the terminal. Typically consumed by the +/// [TerminalInputHandler] to produce a escape sequence that can be recognized +/// by the terminal. +/// +/// See also: +/// - [TerminalInputHandler] +class TerminalKeyboardEvent { + final TerminalKey key; + + final bool shift; + + final bool ctrl; + + final bool alt; + + final TerminalState state; + + final bool altBuffer; + + final TerminalTargetPlatform platform; + + TerminalKeyboardEvent({ + required this.key, + required this.shift, + required this.ctrl, + required this.alt, + required this.state, + required this.altBuffer, + required this.platform, + }); + + TerminalKeyboardEvent copyWith({ + TerminalKey? key, + bool? shift, + bool? ctrl, + bool? alt, + TerminalState? state, + bool? altBuffer, + TerminalTargetPlatform? platform, + }) { + return TerminalKeyboardEvent( + key: key ?? this.key, + shift: shift ?? this.shift, + ctrl: ctrl ?? this.ctrl, + alt: alt ?? this.alt, + state: state ?? this.state, + altBuffer: altBuffer ?? this.altBuffer, + platform: platform ?? this.platform, + ); + } +} + +/// TerminalInputHandler contains the logic for translating a [TerminalKeyboardEvent] +/// into escape sequences that can be recognized by the terminal. +abstract class TerminalInputHandler { + /// Translates a [TerminalKeyboardEvent] into an escape sequence. If the event + /// cannot be translated, null is returned. + String? call(TerminalKeyboardEvent event); +} + +/// A [TerminalInputHandler] that chains multiple handlers together. If any +/// handler returns a non-null value, it is returned. Otherwise, null is +/// returned. +class CascadeInputHandler implements TerminalInputHandler { + final List _handlers; + + const CascadeInputHandler(this._handlers); + + @override + String? call(TerminalKeyboardEvent event) { + for (var handler in _handlers) { + final result = handler(event); + if (result != null) { + return result; + } + } + return null; + } +} + +/// The default input handler for the terminal. That is composed of a +/// [KeytabInputHandler], a [CtrlInputHandler], and a [AltInputHandler]. +/// +/// It's possible to override the default input handler behavior by chaining +/// another input handler before or after the default input handler using +/// [CascadeInputHandler]. +/// +/// See also: +/// * [CascadeInputHandler] +const defaultInputHandler = CascadeInputHandler([ + KeytabInputHandler(), + CtrlInputHandler(), + AltInputHandler(), +]); + +/// A [TerminalInputHandler] that translates key events according to a keytab +/// file. If no keytab is provided, [Keytab.defaultKeytab] is used. +class KeytabInputHandler implements TerminalInputHandler { + const KeytabInputHandler([this.keytab]); + + final Keytab? keytab; + + @override + String? call(TerminalKeyboardEvent event) { + final keytab = this.keytab ?? Keytab.defaultKeytab; + + final record = keytab.find( + event.key, + ctrl: event.ctrl, + alt: event.alt, + shift: event.shift, + newLineMode: event.state.lineFeedMode, + appCursorKeys: event.state.appKeypadMode, + appKeyPad: event.state.appKeypadMode, + appScreen: event.altBuffer, + macos: event.platform == TerminalTargetPlatform.macos, + ); + + if (record == null) { + return null; + } + + var result = record.action.unescapedValue(); + result = insertModifiers(event, result); + return result; + } + + String insertModifiers(TerminalKeyboardEvent event, String action) { + String? code; + + if (event.shift && event.alt && event.ctrl) { + code = '8'; + } else if (event.ctrl && event.alt) { + code = '7'; + } else if (event.shift && event.ctrl) { + code = '6'; + } else if (event.ctrl) { + code = '5'; + } else if (event.shift && event.alt) { + code = '4'; + } else if (event.alt) { + code = '3'; + } else if (event.shift) { + code = '2'; + } + + if (code != null) { + return action.replaceAll('*', code); + } + + return action; + } +} + +/// A [TerminalInputHandler] that translates ctrl + key events into escape +/// sequences. For example, ctrl + a becomes ^A. +class CtrlInputHandler implements TerminalInputHandler { + const CtrlInputHandler(); + + @override + String? call(TerminalKeyboardEvent event) { + if (!event.ctrl || event.shift || event.alt) { + return null; + } + + final key = event.key; + + if (key.index >= TerminalKey.keyA.index && + key.index <= TerminalKey.keyZ.index) { + final input = key.index - TerminalKey.keyA.index + 1; + return String.fromCharCode(input); + } + + return null; + } +} + +/// A [TerminalInputHandler] that translates alt + key events into escape +/// sequences. For example, alt + a becomes ^[a. +class AltInputHandler implements TerminalInputHandler { + const AltInputHandler(); + + @override + String? call(TerminalKeyboardEvent event) { + if (!event.alt || event.ctrl || event.shift) { + return null; + } + + if (event.platform == TerminalTargetPlatform.macos) { + return null; + } + + final key = event.key; + + if (key.index >= TerminalKey.keyA.index && + key.index <= TerminalKey.keyZ.index) { + final charCode = key.index - TerminalKey.keyA.index + 65; + final input = [0x1b, charCode]; + return String.fromCharCodes(input); + } + + return null; + } +} diff --git a/lib/src/terminal/src/core/input/keys.dart b/lib/src/terminal/src/core/input/keys.dart new file mode 100644 index 00000000..9dd20f2c --- /dev/null +++ b/lib/src/terminal/src/core/input/keys.dart @@ -0,0 +1,837 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum TerminalKey { + /// Represents the logical "None" key on the keyboard. + none, + + /// Represents the logical "Hyper" key on the keyboard. + hyper, + + /// Represents the logical "Super Key" key on the keyboard. + superKey, + + /// Represents the logical "Fn Lock" key on the keyboard. + fnLock, + + /// Represents the logical "Suspend" key on the keyboard. + suspend, + + /// Represents the logical "Resume" key on the keyboard. + resume, + + /// Represents the logical "Turbo" key on the keyboard. + turbo, + + /// Represents the logical "Privacy Screen Toggle" key on the keyboard. + privacyScreenToggle, + + /// Represents the logical "Sleep" key on the keyboard. + sleep, + + /// Represents the logical "Wake Up" key on the keyboard. + wakeUp, + + /// Represents the logical "Display Toggle Int Ext" key on the keyboard. + displayToggleIntExt, + + /// Represents the logical "Usb Reserved" key on the keyboard. + usbReserved, + + /// Represents the logical "Usb Error Roll Over" key on the keyboard. + usbErrorRollOver, + + /// Represents the logical "Usb Post Fail" key on the keyboard. + usbPostFail, + + /// Represents the logical "Usb Error Undefined" key on the keyboard. + usbErrorUndefined, + + /// Represents the logical "Key A" key on the keyboard. + keyA, + + /// Represents the logical "Key B" key on the keyboard. + keyB, + + /// Represents the logical "Key C" key on the keyboard. + keyC, + + /// Represents the logical "Key D" key on the keyboard. + keyD, + + /// Represents the logical "Key E" key on the keyboard. + keyE, + + /// Represents the logical "Key F" key on the keyboard. + keyF, + + /// Represents the logical "Key G" key on the keyboard. + keyG, + + /// Represents the logical "Key H" key on the keyboard. + keyH, + + /// Represents the logical "Key I" key on the keyboard. + keyI, + + /// Represents the logical "Key J" key on the keyboard. + keyJ, + + /// Represents the logical "Key K" key on the keyboard. + keyK, + + /// Represents the logical "Key L" key on the keyboard. + keyL, + + /// Represents the logical "Key M" key on the keyboard. + keyM, + + /// Represents the logical "Key N" key on the keyboard. + keyN, + + /// Represents the logical "Key O" key on the keyboard. + keyO, + + /// Represents the logical "Key P" key on the keyboard. + keyP, + + /// Represents the logical "Key Q" key on the keyboard. + keyQ, + + /// Represents the logical "Key R" key on the keyboard. + keyR, + + /// Represents the logical "Key S" key on the keyboard. + keyS, + + /// Represents the logical "Key T" key on the keyboard. + keyT, + + /// Represents the logical "Key U" key on the keyboard. + keyU, + + /// Represents the logical "Key V" key on the keyboard. + keyV, + + /// Represents the logical "Key W" key on the keyboard. + keyW, + + /// Represents the logical "Key X" key on the keyboard. + keyX, + + /// Represents the logical "Key Y" key on the keyboard. + keyY, + + /// Represents the logical "Key Z" key on the keyboard. + keyZ, + + /// Represents the logical "Digit 1" key on the keyboard. + digit1, + + /// Represents the logical "Digit 2" key on the keyboard. + digit2, + + /// Represents the logical "Digit 3" key on the keyboard. + digit3, + + /// Represents the logical "Digit 4" key on the keyboard. + digit4, + + /// Represents the logical "Digit 5" key on the keyboard. + digit5, + + /// Represents the logical "Digit 6" key on the keyboard. + digit6, + + /// Represents the logical "Digit 7" key on the keyboard. + digit7, + + /// Represents the logical "Digit 8" key on the keyboard. + digit8, + + /// Represents the logical "Digit 9" key on the keyboard. + digit9, + + /// Represents the logical "Digit 0" key on the keyboard. + digit0, + + /// Represents the logical "Enter" key on the keyboard. + enter, + + /// Represents the logical "Escape" key on the keyboard. + escape, + + /// Represents the logical "Backspace" key on the keyboard. + backspace, + + /// Represents the logical "Tab" key on the keyboard. + tab, + + /// Represents the logical "Space" key on the keyboard. + space, + + /// Represents the logical "Minus" key on the keyboard. + minus, + + /// Represents the logical "Equal" key on the keyboard. + equal, + + /// Represents the logical "Bracket Left" key on the keyboard. + bracketLeft, + + /// Represents the logical "Bracket Right" key on the keyboard. + bracketRight, + + /// Represents the logical "Backslash" key on the keyboard. + backslash, + + /// Represents the logical "Semicolon" key on the keyboard. + semicolon, + + /// Represents the logical "Quote" key on the keyboard. + quote, + + /// Represents the logical "Backquote" key on the keyboard. + backquote, + + /// Represents the logical "Comma" key on the keyboard. + comma, + + /// Represents the logical "Period" key on the keyboard. + period, + + /// Represents the logical "Slash" key on the keyboard. + slash, + + /// Represents the logical "Caps Lock" key on the keyboard. + capsLock, + + /// Represents the logical "F1" key on the keyboard. + f1, + + /// Represents the logical "F2" key on the keyboard. + f2, + + /// Represents the logical "F3" key on the keyboard. + f3, + + /// Represents the logical "F4" key on the keyboard. + f4, + + /// Represents the logical "F5" key on the keyboard. + f5, + + /// Represents the logical "F6" key on the keyboard. + f6, + + /// Represents the logical "F7" key on the keyboard. + f7, + + /// Represents the logical "F8" key on the keyboard. + f8, + + /// Represents the logical "F9" key on the keyboard. + f9, + + /// Represents the logical "F10" key on the keyboard. + f10, + + /// Represents the logical "F11" key on the keyboard. + f11, + + /// Represents the logical "F12" key on the keyboard. + f12, + + /// Represents the logical "Print Screen" key on the keyboard. + printScreen, + + /// Represents the logical "Scroll Lock" key on the keyboard. + scrollLock, + + /// Represents the logical "Pause" key on the keyboard. + pause, + + /// Represents the logical "Insert" key on the keyboard. + insert, + + /// Represents the logical "Home" key on the keyboard. + home, + + /// Represents the logical "Page Up" key on the keyboard. + pageUp, + + /// Represents the logical "Delete" key on the keyboard. + delete, + + /// Represents the logical "End" key on the keyboard. + end, + + /// Represents the logical "Page Down" key on the keyboard. + pageDown, + + /// Represents the logical "Arrow Right" key on the keyboard. + arrowRight, + + /// Represents the logical "Arrow Left" key on the keyboard. + arrowLeft, + + /// Represents the logical "Arrow Down" key on the keyboard. + arrowDown, + + /// Represents the logical "Arrow Up" key on the keyboard. + arrowUp, + + /// Represents the logical "Num Lock" key on the keyboard. + numLock, + + /// Represents the logical "Numpad Divide" key on the keyboard. + numpadDivide, + + /// Represents the logical "Numpad Multiply" key on the keyboard. + numpadMultiply, + + /// Represents the logical "Numpad Subtract" key on the keyboard. + numpadSubtract, + + /// Represents the logical "Numpad Add" key on the keyboard. + numpadAdd, + + /// Represents the logical "Numpad Enter" key on the keyboard. + numpadEnter, + + /// Represents the logical "Numpad 1" key on the keyboard. + numpad1, + + /// Represents the logical "Numpad 2" key on the keyboard. + numpad2, + + /// Represents the logical "Numpad 3" key on the keyboard. + numpad3, + + /// Represents the logical "Numpad 4" key on the keyboard. + numpad4, + + /// Represents the logical "Numpad 5" key on the keyboard. + numpad5, + + /// Represents the logical "Numpad 6" key on the keyboard. + numpad6, + + /// Represents the logical "Numpad 7" key on the keyboard. + numpad7, + + /// Represents the logical "Numpad 8" key on the keyboard. + numpad8, + + /// Represents the logical "Numpad 9" key on the keyboard. + numpad9, + + /// Represents the logical "Numpad 0" key on the keyboard. + numpad0, + + /// Represents the logical "Numpad Decimal" key on the keyboard. + numpadDecimal, + + /// Represents the logical "Intl Backslash" key on the keyboard. + intlBackslash, + + /// Represents the logical "Context Menu" key on the keyboard. + contextMenu, + + /// Represents the logical "Power" key on the keyboard. + power, + + /// Represents the logical "Numpad Equal" key on the keyboard. + numpadEqual, + + /// Represents the logical "F13" key on the keyboard. + f13, + + /// Represents the logical "F14" key on the keyboard. + f14, + + /// Represents the logical "F15" key on the keyboard. + f15, + + /// Represents the logical "F16" key on the keyboard. + f16, + + /// Represents the logical "F17" key on the keyboard. + f17, + + /// Represents the logical "F18" key on the keyboard. + f18, + + /// Represents the logical "F19" key on the keyboard. + f19, + + /// Represents the logical "F20" key on the keyboard. + f20, + + /// Represents the logical "F21" key on the keyboard. + f21, + + /// Represents the logical "F22" key on the keyboard. + f22, + + /// Represents the logical "F23" key on the keyboard. + f23, + + /// Represents the logical "F24" key on the keyboard. + f24, + + /// Represents the logical "Open" key on the keyboard. + open, + + /// Represents the logical "Help" key on the keyboard. + help, + + /// Represents the logical "Select" key on the keyboard. + select, + + /// Represents the logical "Again" key on the keyboard. + again, + + /// Represents the logical "Undo" key on the keyboard. + undo, + + /// Represents the logical "Cut" key on the keyboard. + cut, + + /// Represents the logical "Copy" key on the keyboard. + copy, + + /// Represents the logical "Paste" key on the keyboard. + paste, + + /// Represents the logical "Find" key on the keyboard. + find, + + /// Represents the logical "Audio Volume Mute" key on the keyboard. + audioVolumeMute, + + /// Represents the logical "Audio Volume Up" key on the keyboard. + audioVolumeUp, + + /// Represents the logical "Audio Volume Down" key on the keyboard. + audioVolumeDown, + + /// Represents the logical "Numpad Comma" key on the keyboard. + numpadComma, + + /// Represents the logical "Intl Ro" key on the keyboard. + intlRo, + + /// Represents the logical "Kana Mode" key on the keyboard. + kanaMode, + + /// Represents the logical "Intl Yen" key on the keyboard. + intlYen, + + /// Represents the logical "Convert" key on the keyboard. + convert, + + /// Represents the logical "Non Convert" key on the keyboard. + nonConvert, + + /// Represents the logical "Lang 1" key on the keyboard. + lang1, + + /// Represents the logical "Lang 2" key on the keyboard. + lang2, + + /// Represents the logical "Lang 3" key on the keyboard. + lang3, + + /// Represents the logical "Lang 4" key on the keyboard. + lang4, + + /// Represents the logical "Lang 5" key on the keyboard. + lang5, + + /// Represents the logical "Abort" key on the keyboard. + abort, + + /// Represents the logical "Props" key on the keyboard. + props, + + /// Represents the logical "Numpad Paren Left" key on the keyboard. + numpadParenLeft, + + /// Represents the logical "Numpad Paren Right" key on the keyboard. + numpadParenRight, + + /// Represents the logical "Numpad Backspace" key on the keyboard. + numpadBackspace, + + /// Represents the logical "Numpad Memory Store" key on the keyboard. + numpadMemoryStore, + + /// Represents the logical "Numpad Memory Recall" key on the keyboard. + numpadMemoryRecall, + + /// Represents the logical "Numpad Memory Clear" key on the keyboard. + numpadMemoryClear, + + /// Represents the logical "Numpad Memory Add" key on the keyboard. + numpadMemoryAdd, + + /// Represents the logical "Numpad Memory Subtract" key on the keyboard. + numpadMemorySubtract, + + /// Represents the logical "Numpad Sign Change" key on the keyboard. + numpadSignChange, + + /// Represents the logical "Numpad Clear" key on the keyboard. + numpadClear, + + /// Represents the logical "Numpad Clear Entry" key on the keyboard. + numpadClearEntry, + + /// Represents the logical "Control Left" key on the keyboard. + controlLeft, + + /// Represents the logical "Shift Left" key on the keyboard. + shiftLeft, + + /// Represents the logical "Alt Left" key on the keyboard. + altLeft, + + /// Represents the logical "Meta Left" key on the keyboard. + metaLeft, + + /// Represents the logical "Control Right" key on the keyboard. + controlRight, + + /// Represents the logical "Shift Right" key on the keyboard. + shiftRight, + + /// Represents the logical "Alt Right" key on the keyboard. + altRight, + + /// Represents the logical "Meta Right" key on the keyboard. + metaRight, + + /// Represents the logical "Info" key on the keyboard. + info, + + /// Represents the logical "Closed Caption Toggle" key on the keyboard. + closedCaptionToggle, + + /// Represents the logical "Brightness Up" key on the keyboard. + brightnessUp, + + /// Represents the logical "Brightness Down" key on the keyboard. + brightnessDown, + + /// Represents the logical "Brightness Toggle" key on the keyboard. + brightnessToggle, + + /// Represents the logical "Brightness Minimum" key on the keyboard. + brightnessMinimum, + + /// Represents the logical "Brightness Maximum" key on the keyboard. + brightnessMaximum, + + /// Represents the logical "Brightness Auto" key on the keyboard. + brightnessAuto, + + /// Represents the logical "Media Last" key on the keyboard. + mediaLast, + + /// Represents the logical "Launch Phone" key on the keyboard. + launchPhone, + + /// Represents the logical "Program Guide" key on the keyboard. + programGuide, + + /// Represents the logical "Exit" key on the keyboard. + exit, + + /// Represents the logical "Channel Up" key on the keyboard. + channelUp, + + /// Represents the logical "Channel Down" key on the keyboard. + channelDown, + + /// Represents the logical "Media Play" key on the keyboard. + mediaPlay, + + /// Represents the logical "Media Pause" key on the keyboard. + mediaPause, + + /// Represents the logical "Media Record" key on the keyboard. + mediaRecord, + + /// Represents the logical "Media Fast Forward" key on the keyboard. + mediaFastForward, + + /// Represents the logical "Media Rewind" key on the keyboard. + mediaRewind, + + /// Represents the logical "Media Track Next" key on the keyboard. + mediaTrackNext, + + /// Represents the logical "Media Track Previous" key on the keyboard. + mediaTrackPrevious, + + /// Represents the logical "Media Stop" key on the keyboard. + mediaStop, + + /// Represents the logical "Eject" key on the keyboard. + eject, + + /// Represents the logical "Media Play Pause" key on the keyboard. + mediaPlayPause, + + /// Represents the logical "Speech Input Toggle" key on the keyboard. + speechInputToggle, + + /// Represents the logical "Bass Boost" key on the keyboard. + bassBoost, + + /// Represents the logical "Media Select" key on the keyboard. + mediaSelect, + + /// Represents the logical "Launch Word Processor" key on the keyboard. + launchWordProcessor, + + /// Represents the logical "Launch Spreadsheet" key on the keyboard. + launchSpreadsheet, + + /// Represents the logical "Launch Mail" key on the keyboard. + launchMail, + + /// Represents the logical "Launch Contacts" key on the keyboard. + launchContacts, + + /// Represents the logical "Launch Calendar" key on the keyboard. + launchCalendar, + + /// Represents the logical "Launch App2" key on the keyboard. + launchApp2, + + /// Represents the logical "Launch App1" key on the keyboard. + launchApp1, + + /// Represents the logical "Launch Internet Browser" key on the keyboard. + launchInternetBrowser, + + /// Represents the logical "Log Off" key on the keyboard. + logOff, + + /// Represents the logical "Lock Screen" key on the keyboard. + lockScreen, + + /// Represents the logical "Launch Control Panel" key on the keyboard. + launchControlPanel, + + /// Represents the logical "Select Task" key on the keyboard. + selectTask, + + /// Represents the logical "Launch Documents" key on the keyboard. + launchDocuments, + + /// Represents the logical "Spell Check" key on the keyboard. + spellCheck, + + /// Represents the logical "Launch Keyboard Layout" key on the keyboard. + launchKeyboardLayout, + + /// Represents the logical "Launch Screen Saver" key on the keyboard. + launchScreenSaver, + + /// Represents the logical "Launch Assistant" key on the keyboard. + launchAssistant, + + /// Represents the logical "Launch Audio Browser" key on the keyboard. + launchAudioBrowser, + + /// Represents the logical "New Key" key on the keyboard. + newKey, + + /// Represents the logical "Close" key on the keyboard. + close, + + /// Represents the logical "Save" key on the keyboard. + save, + + /// Represents the logical "Print" key on the keyboard. + print, + + /// Represents the logical "Browser Search" key on the keyboard. + browserSearch, + + /// Represents the logical "Browser Home" key on the keyboard. + browserHome, + + /// Represents the logical "Browser Back" key on the keyboard. + browserBack, + + /// Represents the logical "Browser Forward" key on the keyboard. + browserForward, + + /// Represents the logical "Browser Stop" key on the keyboard. + browserStop, + + /// Represents the logical "Browser Refresh" key on the keyboard. + browserRefresh, + + /// Represents the logical "Browser Favorites" key on the keyboard. + browserFavorites, + + /// Represents the logical "Zoom In" key on the keyboard. + zoomIn, + + /// Represents the logical "Zoom Out" key on the keyboard. + zoomOut, + + /// Represents the logical "Zoom Toggle" key on the keyboard. + zoomToggle, + + /// Represents the logical "Redo" key on the keyboard. + redo, + + /// Represents the logical "Mail Reply" key on the keyboard. + mailReply, + + /// Represents the logical "Mail Forward" key on the keyboard. + mailForward, + + /// Represents the logical "Mail Send" key on the keyboard. + mailSend, + + /// Represents the logical "Keyboard Layout Select" key on the keyboard. + keyboardLayoutSelect, + + /// Represents the logical "Show All Windows" key on the keyboard. + showAllWindows, + + /// Represents the logical "Game Button 1" key on the keyboard. + gameButton1, + + /// Represents the logical "Game Button 2" key on the keyboard. + gameButton2, + + /// Represents the logical "Game Button 3" key on the keyboard. + gameButton3, + + /// Represents the logical "Game Button 4" key on the keyboard. + gameButton4, + + /// Represents the logical "Game Button 5" key on the keyboard. + gameButton5, + + /// Represents the logical "Game Button 6" key on the keyboard. + gameButton6, + + /// Represents the logical "Game Button 7" key on the keyboard. + gameButton7, + + /// Represents the logical "Game Button 8" key on the keyboard. + gameButton8, + + /// Represents the logical "Game Button 9" key on the keyboard. + gameButton9, + + /// Represents the logical "Game Button 10" key on the keyboard. + gameButton10, + + /// Represents the logical "Game Button 11" key on the keyboard. + gameButton11, + + /// Represents the logical "Game Button 12" key on the keyboard. + gameButton12, + + /// Represents the logical "Game Button 13" key on the keyboard. + gameButton13, + + /// Represents the logical "Game Button 14" key on the keyboard. + gameButton14, + + /// Represents the logical "Game Button 15" key on the keyboard. + gameButton15, + + /// Represents the logical "Game Button 16" key on the keyboard. + gameButton16, + + /// Represents the logical "Game Button A" key on the keyboard. + gameButtonA, + + /// Represents the logical "Game Button B" key on the keyboard. + gameButtonB, + + /// Represents the logical "Game Button C" key on the keyboard. + gameButtonC, + + /// Represents the logical "Game Button Left 1" key on the keyboard. + gameButtonLeft1, + + /// Represents the logical "Game Button Left 2" key on the keyboard. + gameButtonLeft2, + + /// Represents the logical "Game Button Mode" key on the keyboard. + gameButtonMode, + + /// Represents the logical "Game Button Right 1" key on the keyboard. + gameButtonRight1, + + /// Represents the logical "Game Button Right 2" key on the keyboard. + gameButtonRight2, + + /// Represents the logical "Game Button Select" key on the keyboard. + gameButtonSelect, + + /// Represents the logical "Game Button Start" key on the keyboard. + gameButtonStart, + + /// Represents the logical "Game Button Thumb Left" key on the keyboard. + gameButtonThumbLeft, + + /// Represents the logical "Game Button Thumb Right" key on the keyboard. + gameButtonThumbRight, + + /// Represents the logical "Game Button X" key on the keyboard. + gameButtonX, + + /// Represents the logical "Game Button Y" key on the keyboard. + gameButtonY, + + /// Represents the logical "Game Button Z" key on the keyboard. + gameButtonZ, + + /// Represents the logical "Fn" key on the keyboard. + fn, + + /// Represents the logical "Shift" key on the keyboard. + /// + /// This key represents the union of the keys {shiftLeft, shiftRight} when + /// comparing keys. This key will never be generated directly, its main use is + /// in defining key maps. + shift, + + /// Represents the logical "Meta" key on the keyboard. + /// + /// This key represents the union of the keys {metaLeft, metaRight} when + /// comparing keys. This key will never be generated directly, its main use is + /// in defining key maps. + meta, + + /// Represents the logical "Alt" key on the keyboard. + /// + /// This key represents the union of the keys {altLeft, altRight} when + /// comparing keys. This key will never be generated directly, its main use is + /// in defining key maps. + alt, + + /// Represents the logical "Control" key on the keyboard. + /// + /// This key represents the union of the keys {controlLeft, controlRight} when + /// comparing keys. This key will never be generated directly, its main use is + /// in defining key maps. + control, + + // Missing flutter keys. + + backtab, + returnKey, +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab.dart b/lib/src/terminal/src/core/input/keytab/keytab.dart new file mode 100644 index 00000000..fab05910 --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab.dart @@ -0,0 +1,115 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_default.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_parse.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_record.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_token.dart'; + +class Keytab { + Keytab({ + required this.name, + required this.records, + }); + + factory Keytab.parse(String source) { + final tokens = tokenize(source).toList(); + final parser = KeytabParser()..addTokens(tokens); + return parser.result; + } + + static final defaultKeytab = Keytab.parse(kDefaultKeytab); + + final String? name; + + final List records; + + KeytabRecord? find( + TerminalKey key, { + bool ctrl = false, + bool alt = false, + bool shift = false, + bool newLineMode = false, + bool appCursorKeys = false, + bool appKeyPad = false, + bool keyPad = false, + bool appScreen = false, + bool macos = false, + // bool meta, + }) { + for (var record in records) { + if (record.key != key) { + continue; + } + + if (record.anyModifier == true) { + if (ctrl == false && alt == false && shift == false) { + continue; + } + } else if (record.anyModifier == false) { + if (ctrl != false || alt != false || shift != false) { + continue; + } + } else { + if (record.ctrl != null && record.ctrl != ctrl) { + continue; + } + + if (record.shift != null && record.shift != shift) { + continue; + } + + if (record.alt != null && record.alt != alt) { + continue; + } + } + + if (record.newLine != null && record.newLine != newLineMode) { + continue; + } + + if (record.appCursorKeys != null && + record.appCursorKeys != appCursorKeys) { + continue; + } + + if (record.appKeyPad != null && record.appKeyPad != appKeyPad) { + continue; + } + + if (record.keyPad != null && record.keyPad != keyPad) { + continue; + } + + if (record.appScreen != null && record.appScreen != appScreen) { + continue; + } + + if (record.macos != null && record.macos != macos) { + continue; + } + + // TODO: support VT52 + if (record.ansi == false) { + continue; + } + + return record; + } + + return null; + } + + @override + String toString() { + final buffer = StringBuffer(); + + buffer.writeln('keyboard "$name"'); + + for (var record in records) { + buffer.writeln(record); + } + + return buffer.toString(); + } +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab_default.dart b/lib/src/terminal/src/core/input/keytab/keytab_default.dart new file mode 100644 index 00000000..92b18f67 --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab_default.dart @@ -0,0 +1,215 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keytab/keytab_parse.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_token.dart'; + +const kDefaultKeytab = r''' +# [README.default.Keytab] Default Keyboard Table +# +# To customize your keyboard, copy this file to something ending with +# .keytab and change it to meet you needs. +# +# Please read the README-KeyTab and the doc/user/README.keyboard files +# in this case. +# +# -------------------------------------------------------------- + +keyboard "Default (XFree 4)" + +# -------------------------------------------------------------- +# +# Note that this particular table is a "risc" version made to +# ease customization without bothering with obsolete details. +# See VT100.keytab for the more hairy stuff. +# +# -------------------------------------------------------------- + +# common keys + +key Escape : "\E" + +key Tab -Shift : "\t" +key Tab +Shift+Ansi : "\E[Z" +key Tab +Shift-Ansi : "\t" +key Backtab +Ansi : "\E[Z" +key Backtab -Ansi : "\t" + +key Return-Shift-NewLine : "\r" +key Return-Shift+NewLine : "\r\n" + +key Return+Shift : "\EOM" + +key Backspace +Alt : "\x17" + +# Backspace and Delete codes are preserving CTRL-H. +# +# Backspace without CTRL sends '^?'; this matches XTerm behaviour, so that +# pressing Alt+Backspace will send \E + Del, which is the expected behaviour +# in some apps (e.g. emacs), and it was the behaviour before the commit +# that add the Backspace +Control rule +key Backspace -Control : "\x7f" + +# Match xterm behaviour: Backspace sends '^H' when Control is pressed +# BS, hex \x08, \b +key Backspace +Control : "\b" + +# Arrow keys in VT52 mode +# shift up/down are reserved for scrolling. +# shift left/right are reserved for switching between tabs (this is hardcoded). + +key Up -Shift-Ansi : "\EA" +key Down -Shift-Ansi : "\EB" +key Right-Shift-Ansi : "\EC" +key Left -Shift-Ansi : "\ED" + +# Arrow keys in ANSI mode with Application - and Normal Cursor Mode) + +key Up -Shift-AnyMod+Ansi+AppCuKeys : "\EOA" +key Down -Shift-AnyMod+Ansi+AppCuKeys : "\EOB" +key Right -Shift-AnyMod+Ansi+AppCuKeys : "\EOC" +key Left -Shift-AnyMod+Ansi+AppCuKeys : "\EOD" + +key Up -Shift-AnyMod+Ansi-AppCuKeys : "\E[A" +key Down -Shift-AnyMod+Ansi-AppCuKeys : "\E[B" +key Right -Shift-AnyMod+Ansi-AppCuKeys : "\E[C" +key Left -Shift-AnyMod+Ansi-AppCuKeys : "\E[D" + +key Up -Shift+AnyMod+Ansi : "\E[1;5A" +key Down -Shift+AnyMod+Ansi : "\E[1;5B" + +# Right / Left with Control +key Right -Shift-Alt+Control+Ansi : "\E[1;5C" +key Left -Shift-Alt+Control+Ansi : "\E[1;5D" + +# Right / Left with Alt not on a Mac +key Right -Shift+Alt-Control+Ansi-Mac : "\E[1;5C" +key Left -Shift+Alt-Control+Ansi-Mac : "\E[1;5D" + +# Right / Left with Alt on a Mac +key Right -Shift+Alt-Control+Ansi+Mac : "\Ef" +key Left -Shift+Alt-Control+Ansi+Mac : "\Eb" + +key Up +Shift+AppScreen : "\E[1;*A" +key Down +Shift+AppScreen : "\E[1;*B" +key Left +Shift+AppScreen : "\E[1;*D" +key Right +Shift+AppScreen : "\E[1;*C" + +# Keypad keys with NumLock ON +# (see https://web.archive.org/web/20070807181942/http://www.nw.com/nw/WWW/products/wizcon/vt100.html +# https://vt100.net/docs/vt100-ug/chapter3.html) +# +# Not enabled for now because it breaks the keypad in Vim. +# +#key 0 +KeyPad+AppKeyPad : "\EOp" +#key 1 +KeyPad+AppKeyPad : "\EOq" +#key 2 +KeyPad+AppKeyPad : "\EOr" +#key 3 +KeyPad+AppKeyPad : "\EOs" +#key 4 +KeyPad+AppKeyPad : "\EOt" +#key 5 +KeyPad+AppKeyPad : "\EOu" +#key 6 +KeyPad+AppKeyPad : "\EOv" +#key 7 +KeyPad+AppKeyPad : "\EOw" +#key 8 +KeyPad+AppKeyPad : "\EOx" +#key 9 +KeyPad+AppKeyPad : "\EOy" +#key + +KeyPad+AppKeyPad : "\EOl" +#key - +KeyPad+AppKeyPad : "\EOm" +#key . +KeyPad+AppKeyPad : "\EOn" +#key * +KeyPad+AppKeyPad : "\EOM" +#key Enter +KeyPad+AppKeyPad : "\r" + +# Keypad keys with NumLock Off +key Up -Shift+Ansi+AppCuKeys+KeyPad : "\EOA" +key Down -Shift+Ansi+AppCuKeys+KeyPad : "\EOB" +key Right -Shift+Ansi+AppCuKeys+KeyPad : "\EOC" +key Left -Shift+Ansi+AppCuKeys+KeyPad : "\EOD" + +key Up -Shift+Ansi-AppCuKeys+KeyPad : "\E[A" +key Down -Shift+Ansi-AppCuKeys+KeyPad : "\E[B" +key Right -Shift+Ansi-AppCuKeys+KeyPad : "\E[C" +key Left -Shift+Ansi-AppCuKeys+KeyPad : "\E[D" + +key Home +AppCuKeys+KeyPad : "\EOH" +key End +AppCuKeys+KeyPad : "\EOF" +key Home -AppCuKeys+KeyPad : "\E[H" +key End -AppCuKeys+KeyPad : "\E[F" + +key Insert +KeyPad : "\E[2~" +key Delete +KeyPad : "\E[3~" +key PgUp -Shift+KeyPad : "\E[5~" +key PgDown -Shift+KeyPad : "\E[6~" + +# the key labelled 5 on the Keypad, is Qt::Key_Clear (a very intuitive +# and discoverable name...) +key Clear +KeyPad : "\E[E" + +# other grey PC keys + +key Enter+NewLine : "\r\n" +key Enter-NewLine : "\r" + +key NumEnter+NewLine : "\r\n" +key NumEnter-NewLine : "\r" + +key Home -AnyMod-AppCuKeys : "\E[H" +key End -AnyMod-AppCuKeys : "\E[F" +key Home -AnyMod+AppCuKeys : "\EOH" +key End -AnyMod+AppCuKeys : "\EOF" +key Home +AnyMod : "\E[1;*H" +key End +AnyMod : "\E[1;*F" + +key Insert -AnyMod : "\E[2~" +key Delete -AnyMod : "\E[3~" +key Insert +AnyMod : "\E[2;*~" +key Delete +AnyMod : "\E[3;*~" + +key PgUp -Shift-AnyMod : "\E[5~" +key PgDown -Shift-AnyMod : "\E[6~" +key PgUp -Shift+AnyMod : "\E[5;*~" +key PgDown -Shift+AnyMod : "\E[6;*~" + +# Function keys +key F1 -AnyMod : "\EOP" +key F2 -AnyMod : "\EOQ" +key F3 -AnyMod : "\EOR" +key F4 -AnyMod : "\EOS" +key F5 -AnyMod : "\E[15~" +key F6 -AnyMod : "\E[17~" +key F7 -AnyMod : "\E[18~" +key F8 -AnyMod : "\E[19~" +key F9 -AnyMod : "\E[20~" +key F10 -AnyMod : "\E[21~" +key F11 -AnyMod : "\E[23~" +key F12 -AnyMod : "\E[24~" + +key F1 +AnyMod : "\EO*P" +key F2 +AnyMod : "\EO*Q" +key F3 +AnyMod : "\EO*R" +key F4 +AnyMod : "\EO*S" +key F5 +AnyMod : "\E[15;*~" +key F6 +AnyMod : "\E[17;*~" +key F7 +AnyMod : "\E[18;*~" +key F8 +AnyMod : "\E[19;*~" +key F9 +AnyMod : "\E[20;*~" +key F10 +AnyMod : "\E[21;*~" +key F11 +AnyMod : "\E[23;*~" +key F12 +AnyMod : "\E[24;*~" + +# Work around dead keys + +key Space +Control : "\x00" + +# Some keys are used by konsole to cause operations. +# The scroll* operations refer to the history buffer. + +key Up +Shift-AppScreen : scrollLineUp +key PgUp +Shift-AppScreen : scrollPageUp +key Home +Shift-AppScreen : scrollUpToTop +key Down +Shift-AppScreen : scrollLineDown +key PgDown +Shift-AppScreen : scrollPageDown +key End +Shift-AppScreen : scrollDownToBottom +'''; + +void main() { + final tokens = tokenize(kDefaultKeytab).toList(); + final parser = KeytabParser()..addTokens(tokens); + print(parser.result); +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab_escape.dart b/lib/src/terminal/src/core/input/keytab/keytab_escape.dart new file mode 100644 index 00000000..a4965c2d --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab_escape.dart @@ -0,0 +1,23 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +final _esc = String.fromCharCode(0x1b); + +String keytabUnescape(String str) { + str = str + .replaceAll(r'\E', _esc) + .replaceAll(r'\\', '\\') + .replaceAll(r'\"', '"') + .replaceAll(r'\t', '\t') + .replaceAll(r'\r', '\r') + .replaceAll(r'\n', '\n') + .replaceAll(r'\b', '\b'); + + final hexPattern = RegExp(r'\\x([0-9a-fA-F][0-9a-fA-F])'); + str = str.replaceAllMapped(hexPattern, (match) { + final hexString = match.group(1)!; + final hexValue = int.parse(hexString, radix: 16); + return String.fromCharCode(hexValue); + }); + + return str; +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab_parse.dart b/lib/src/terminal/src/core/input/keytab/keytab_parse.dart new file mode 100644 index 00000000..6091bdd2 --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab_parse.dart @@ -0,0 +1,188 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keytab/keytab.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_record.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_token.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/qt_keyname.dart'; + +class ParseError {} + +class TokensReader { + TokensReader(this.tokens); + + final List tokens; + + var _pos = 0; + + bool get done => _pos > tokens.length - 1; + + KeytabToken? take() { + final result = peek(); + _pos += 1; + return result; + } + + KeytabToken? peek() { + if (done) return null; + return tokens[_pos]; + } +} + +class KeytabParser { + String? _name; + final _records = []; + + void addTokens(List tokens) { + final reader = TokensReader(tokens); + + while (!reader.done) { + if (reader.peek()!.type == KeytabTokenType.keyboard) { + _parseName(reader); + continue; + } + + if (reader.peek()!.type == KeytabTokenType.keyDefine) { + _parseKeyDefine(reader); + continue; + } + + throw ParseError(); + } + } + + Keytab get result { + return Keytab(name: _name, records: _records); + } + + void _parseName(TokensReader reader) { + if (reader.take()!.type != KeytabTokenType.keyboard) { + throw ParseError(); + } + + final name = reader.take()!; + if (name.type != KeytabTokenType.input) { + throw ParseError(); + } + + _name = name.value; + } + + void _parseKeyDefine(TokensReader reader) { + if (reader.take()!.type != KeytabTokenType.keyDefine) { + throw ParseError(); + } + + final keyName = reader.take()!; + + if (keyName.type != KeytabTokenType.keyName) { + throw ParseError(); + } + + final key = qtKeynameMap[keyName.value]; + if (key == null) { + throw ParseError(); + } + + bool? alt; + bool? ctrl; + bool? shift; + bool? anyModifier; + bool? ansi; + bool? appScreen; + bool? keyPad; + bool? appCursorKeys; + bool? appKeyPad; + bool? newLine; + bool? mac; + + while (reader.peek()!.type == KeytabTokenType.modeStatus) { + bool modeStatus; + switch (reader.take()!.value) { + case '+': + modeStatus = true; + break; + case '-': + modeStatus = false; + break; + default: + throw ParseError(); + } + + final mode = reader.take(); + if (mode!.type != KeytabTokenType.mode) { + throw ParseError(); + } + + switch (mode.value) { + case 'Alt': + alt = modeStatus; + break; + case 'Control': + ctrl = modeStatus; + break; + case 'Shift': + shift = modeStatus; + break; + case 'AnyMod': + anyModifier = modeStatus; + break; + case 'Ansi': + ansi = modeStatus; + break; + case 'AppScreen': + appScreen = modeStatus; + break; + case 'KeyPad': + keyPad = modeStatus; + break; + case 'AppCuKeys': + appCursorKeys = modeStatus; + break; + case 'AppKeyPad': + appKeyPad = modeStatus; + break; + case 'NewLine': + newLine = modeStatus; + break; + case 'Mac': + mac = modeStatus; + break; + default: + throw ParseError(); + } + } + + if (reader.take()!.type != KeytabTokenType.colon) { + throw ParseError(); + } + + final actionToken = reader.take()!; + KeytabAction action; + if (actionToken.type == KeytabTokenType.input) { + action = KeytabAction(KeytabActionType.input, actionToken.value); + } else if (actionToken.type == KeytabTokenType.shortcut) { + action = KeytabAction(KeytabActionType.shortcut, actionToken.value); + } else { + throw ParseError(); + } + + final record = KeytabRecord( + qtKeyName: keyName.value, + key: key, + action: action, + alt: alt, + ctrl: ctrl, + shift: shift, + anyModifier: anyModifier, + ansi: ansi, + appScreen: appScreen, + keyPad: keyPad, + appCursorKeys: appCursorKeys, + appKeyPad: appKeyPad, + newLine: newLine, + macos: mac, + ); + + _records.add(record); + } +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab_record.dart b/lib/src/terminal/src/core/input/keytab/keytab_record.dart new file mode 100644 index 00000000..be48649e --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab_record.dart @@ -0,0 +1,136 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_escape.dart'; + +enum KeytabActionType { + input, + shortcut, +} + +class KeytabAction { + KeytabAction(this.type, this.value); + + final KeytabActionType type; + + final String value; + + String unescapedValue() { + if (type == KeytabActionType.input) { + return keytabUnescape(value); + } else { + return value; + } + } + + @override + String toString() { + switch (type) { + case KeytabActionType.input: + return '"$value"'; + case KeytabActionType.shortcut: + return value; + } + } +} + +class KeytabRecord { + KeytabRecord({ + required this.qtKeyName, + required this.key, + required this.action, + required this.alt, + required this.ctrl, + required this.shift, + required this.anyModifier, + required this.ansi, + required this.appScreen, + required this.keyPad, + required this.appCursorKeys, + required this.appKeyPad, + required this.newLine, + required this.macos, + }); + + String qtKeyName; + TerminalKey key; + KeytabAction action; + + bool? alt; + bool? ctrl; + bool? shift; + bool? anyModifier; + bool? ansi; + bool? appScreen; + bool? keyPad; + bool? appCursorKeys; + bool? appKeyPad; + bool? newLine; + bool? macos; + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write('$qtKeyName '); + + if (alt != null) { + buffer.write(_toMode(alt!, 'Alt')); + } + + if (ctrl != null) { + buffer.write(_toMode(ctrl!, 'Control')); + } + + if (shift != null) { + buffer.write(_toMode(shift!, 'Shift')); + } + + if (anyModifier != null) { + buffer.write(_toMode(anyModifier!, 'AnyMod')); + } + + if (ansi != null) { + buffer.write(_toMode(ansi!, 'Ansi')); + } + + if (appScreen != null) { + buffer.write(_toMode(appScreen!, 'AppScreen')); + } + + if (keyPad != null) { + buffer.write(_toMode(keyPad!, 'KeyPad')); + } + + if (appCursorKeys != null) { + buffer.write(_toMode(appCursorKeys!, 'AppCuKeys')); + } + + if (appKeyPad != null) { + buffer.write(_toMode(appKeyPad!, 'AppKeyPad')); + } + + if (newLine != null) { + buffer.write(_toMode(newLine!, 'NewLine')); + } + + if (macos != null) { + buffer.write(_toMode(macos!, 'Mac')); + } + + buffer.write(' : $action'); + + return buffer.toString(); + } + + static String _toMode(bool status, String mode) { + if (status == true) { + return '+$mode'; + } + + if (status == false) { + return '-$mode'; + } + + return ''; + } +} diff --git a/lib/src/terminal/src/core/input/keytab/keytab_token.dart b/lib/src/terminal/src/core/input/keytab/keytab_token.dart new file mode 100644 index 00000000..28dc1b25 --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/keytab_token.dart @@ -0,0 +1,181 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show min; + +enum KeytabTokenType { + keyDefine, + keyboard, + keyName, + mode, + modeStatus, + colon, + input, + shortcut, +} + +class KeytabToken { + KeytabToken(this.type, this.value); + + final KeytabTokenType type; + final String value; + + @override + String toString() { + return '$type<$value>'; + } +} + +class LineReader { + LineReader(this.line); + + final String line; + + var _pos = 0; + + bool get done => _pos > line.length - 1; + + String? take([int count = 1]) { + final result = peek(count); + _pos += count; + return result; + } + + String? peek([int count = 1]) { + if (done) return null; + final end = min(_pos + count, line.length); + final result = line.substring(_pos, end); + return result; + } + + void skipWhitespace() { + while (peek() == ' ' || peek() == '\t') { + _pos += 1; + } + } + + String readString() { + final buffer = StringBuffer(); + final pattern = RegExp(r'\w|_'); + + while (!done && line[_pos].contains(pattern)) { + buffer.write(line[_pos]); + _pos++; + } + + return buffer.toString(); + } + + String readUntil(Pattern pattern, {bool inclusive = false}) { + final buffer = StringBuffer(); + + while (!done && !line[_pos].contains(pattern)) { + buffer.write(line[_pos]); + _pos++; + } + + if (!done && inclusive) { + buffer.write(line[_pos]); + _pos++; + } + + return buffer.toString(); + } +} + +class TokenizeError {} + +Iterable tokenize(String source) sync* { + final lines = source.split('\n'); + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + line = line.replaceFirst(RegExp('#.*'), ''); + + if (line == '') { + continue; + } + + if (_isKeyboardNameDefine(line)) { + yield* _parseKeyboardNameDefine(line); + } + + if (_isKeyDefine(line)) { + yield* _parseKeyDefine(line); + } + } +} + +bool _isKeyboardNameDefine(String line) { + return line.startsWith('keyboard '); +} + +bool _isKeyDefine(String line) { + return line.startsWith('key '); +} + +Iterable _parseKeyboardNameDefine(String line) sync* { + final reader = LineReader(line.trim()); + + if (reader.readString() == 'keyboard') { + yield KeytabToken(KeytabTokenType.keyboard, 'keyboard'); + } else { + throw TokenizeError(); + } + + reader.skipWhitespace(); + + yield _readInput(reader); +} + +Iterable _parseKeyDefine(String line) sync* { + final reader = LineReader(line.trim()); + + if (reader.readString() == 'key') { + yield KeytabToken(KeytabTokenType.keyDefine, 'key'); + } else { + throw TokenizeError(); + } + + reader.skipWhitespace(); + + final keyName = reader.readString(); + yield KeytabToken(KeytabTokenType.keyName, keyName); + + reader.skipWhitespace(); + + while (reader.peek() == '+' || reader.peek() == '-') { + final modeStatus = reader.take()!; + yield KeytabToken(KeytabTokenType.modeStatus, modeStatus); + final mode = reader.readString(); + yield KeytabToken(KeytabTokenType.mode, mode); + reader.skipWhitespace(); + } + + if (reader.take() == ':') { + yield KeytabToken(KeytabTokenType.colon, ':'); + } else { + throw TokenizeError(); + } + + reader.skipWhitespace(); + + if (reader.peek() == '"') { + yield _readInput(reader); + } else { + final action = reader.readString(); + yield KeytabToken(KeytabTokenType.shortcut, action); + } +} + +KeytabToken _readInput(LineReader reader) { + reader.skipWhitespace(); + + if (reader.take() != '"') { + throw TokenizeError(); + } + + final value = reader.readUntil('"'); + reader.take(); + + return KeytabToken(KeytabTokenType.input, value); +} diff --git a/lib/src/terminal/src/core/input/keytab/qt_keyname.dart b/lib/src/terminal/src/core/input/keytab/qt_keyname.dart new file mode 100644 index 00000000..2a70962d --- /dev/null +++ b/lib/src/terminal/src/core/input/keytab/qt_keyname.dart @@ -0,0 +1,479 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/input/keys.dart'; + +/// See: https://doc.qt.io/qt-5/qt.html#Key-enum +const qtKeynameMap = { + 'Escape': TerminalKey.escape, + 'Tab': TerminalKey.tab, + 'Backtab': TerminalKey.backtab, + 'Backspace': TerminalKey.backspace, + 'Return': TerminalKey.returnKey, + 'Enter': TerminalKey.enter, + 'NumEnter': TerminalKey.numpadEnter, + 'Insert': TerminalKey.insert, + 'Delete': TerminalKey.delete, + 'Pause': TerminalKey.pause, + 'Print': TerminalKey.print, +// 'SysReq': TerminalKey.sysReq, + 'Clear': TerminalKey.numpadClear, + 'Home': TerminalKey.home, + 'End': TerminalKey.end, + 'Left': TerminalKey.arrowLeft, + 'Up': TerminalKey.arrowUp, + 'Right': TerminalKey.arrowRight, + 'Down': TerminalKey.arrowDown, + 'PageUp': TerminalKey.pageUp, + 'PageDown': TerminalKey.pageDown, + 'PgUp': TerminalKey.pageUp, + 'PgDown': TerminalKey.pageDown, + 'Shift': TerminalKey.shift, + 'Control': TerminalKey.control, + 'Meta': TerminalKey.meta, + 'Alt': TerminalKey.alt, +// 'AltGr': TerminalKey.altGr, + 'CapsLock': TerminalKey.capsLock, + 'NumLock': TerminalKey.numLock, + 'ScrollLock': TerminalKey.scrollLock, + 'F1': TerminalKey.f1, + 'F2': TerminalKey.f2, + 'F3': TerminalKey.f3, + 'F4': TerminalKey.f4, + 'F5': TerminalKey.f5, + 'F6': TerminalKey.f6, + 'F7': TerminalKey.f7, + 'F8': TerminalKey.f8, + 'F9': TerminalKey.f9, + 'F10': TerminalKey.f10, + 'F11': TerminalKey.f11, + 'F12': TerminalKey.f12, + 'F13': TerminalKey.f13, + 'F14': TerminalKey.f14, + 'F15': TerminalKey.f15, + 'F16': TerminalKey.f16, + 'F17': TerminalKey.f17, + 'F18': TerminalKey.f18, + 'F19': TerminalKey.f19, + 'F20': TerminalKey.f20, + 'F21': TerminalKey.f21, + 'F22': TerminalKey.f22, + 'F23': TerminalKey.f23, + 'F24': TerminalKey.f24, +// 'F25': TerminalKey.f25, +// 'F26': TerminalKey.f26, +// 'F27': TerminalKey.f27, +// 'F28': TerminalKey.f28, +// 'F29': TerminalKey.f29, +// 'F30': TerminalKey.f30, +// 'F31': TerminalKey.f31, +// 'F32': TerminalKey.f32, +// 'F33': TerminalKey.f33, +// 'F34': TerminalKey.f34, +// 'F35': TerminalKey.f35, +// 'Super_L': TerminalKey.super_L, +// 'Super_R': TerminalKey.super_R, +// 'Menu': TerminalKey.menu, +// 'Hyper_L': TerminalKey.hyper_L, +// 'Hyper_R': TerminalKey.hyper_R, + 'Help': TerminalKey.help, +// 'Direction_L': TerminalKey.direction_L, +// 'Direction_R': TerminalKey.direction_R, + 'Space': TerminalKey.space, + // 'Any': TerminalKey.any, + // 'Exclam': TerminalKey.exclam, + // 'QuoteDbl': TerminalKey.quoteDbl, + // 'NumberSign': TerminalKey.numberSign, + // 'Dollar': TerminalKey.dollar, + // 'Percent': TerminalKey.percent, + // 'Ampersand': TerminalKey.ampersand, + // 'Apostrophe': TerminalKey.apostrophe, + 'ParenLeft': TerminalKey.numpadParenLeft, + 'ParenRight': TerminalKey.numpadParenRight, + // 'Asterisk': TerminalKey.asterisk, + // 'Plus': TerminalKey.plus, + 'Comma': TerminalKey.comma, + 'Minus': TerminalKey.minus, + 'Period': TerminalKey.period, + 'Slash': TerminalKey.slash, + '0': TerminalKey.digit0, + '1': TerminalKey.digit1, + '2': TerminalKey.digit2, + '3': TerminalKey.digit3, + '4': TerminalKey.digit4, + '5': TerminalKey.digit5, + '6': TerminalKey.digit6, + '7': TerminalKey.digit7, + '8': TerminalKey.digit8, + '9': TerminalKey.digit9, +// 'Colon': TerminalKey.colon, + 'Semicolon': TerminalKey.semicolon, +// 'Less': TerminalKey.less, +// 'Equal': TerminalKey.equal, +// 'Greater': TerminalKey.greater, +// 'Question': TerminalKey.question, +// 'At': TerminalKey.at, + 'A': TerminalKey.keyA, + 'B': TerminalKey.keyB, + 'C': TerminalKey.keyC, + 'D': TerminalKey.keyD, + 'E': TerminalKey.keyE, + 'F': TerminalKey.keyF, + 'G': TerminalKey.keyG, + 'H': TerminalKey.keyH, + 'I': TerminalKey.keyI, + 'J': TerminalKey.keyJ, + 'K': TerminalKey.keyK, + 'L': TerminalKey.keyL, + 'M': TerminalKey.keyM, + 'N': TerminalKey.keyN, + 'O': TerminalKey.keyO, + 'P': TerminalKey.keyP, + 'Q': TerminalKey.keyQ, + 'R': TerminalKey.keyR, + 'S': TerminalKey.keyS, + 'T': TerminalKey.keyT, + 'U': TerminalKey.keyU, + 'V': TerminalKey.keyV, + 'W': TerminalKey.keyW, + 'X': TerminalKey.keyX, + 'Y': TerminalKey.keyY, + 'Z': TerminalKey.keyZ, + 'BracketLeft': TerminalKey.bracketLeft, + 'Backslash': TerminalKey.backslash, + 'BracketRight': TerminalKey.bracketRight, +// 'AsciiCircum': TerminalKey.asciiCircum, + // 'Underscore': TerminalKey.underscore, + // 'QuoteLeft': TerminalKey.quoteLeft, +// 'BraceLeft': TerminalKey.braceLeft, + // 'Bar': TerminalKey.bar, +// 'BraceRight': TerminalKey.braceRight, + // 'AsciiTilde': TerminalKey.asciiTilde, +// 'nobreakspace': TerminalKey.nobreakspace, +// 'exclamdown': TerminalKey.exclamdown, +// 'cent': TerminalKey.cent, +// 'sterling': TerminalKey.sterling, +// 'currency': TerminalKey.currency, +// 'yen': TerminalKey.yen, +// 'brokenbar': TerminalKey.brokenbar, +// 'section': TerminalKey.section, +// 'diaeresis': TerminalKey.diaeresis, +// 'copyright': TerminalKey.copyright, +// 'ordfeminine': TerminalKey.ordfeminine, +// 'guillemotleft': TerminalKey.guillemotleft, +// 'notsign': TerminalKey.notsign, +// 'hyphen': TerminalKey.hyphen, +// 'registered': TerminalKey.registered, +// 'macron': TerminalKey.macron, +// 'degree': TerminalKey.degree, +// 'plusminus': TerminalKey.plusminus, +// 'twosuperior': TerminalKey.twosuperior, +// 'threesuperior': TerminalKey.threesuperior, +// 'acute': TerminalKey.acute, +// // 'mu': TerminalKey.mu, +// 'paragraph': TerminalKey.paragraph, +// 'periodcentered': TerminalKey.periodcentered, +// 'cedilla': TerminalKey.cedilla, +// 'onesuperior': TerminalKey.onesuperior, +// 'masculine': TerminalKey.masculine, +// 'guillemotright': TerminalKey.guillemotright, +// 'onequarter': TerminalKey.onequarter, +// 'onehalf': TerminalKey.onehalf, +// 'threequarters': TerminalKey.threequarters, +// 'questiondown': TerminalKey.questiondown, +// 'Agrave': TerminalKey.agrave, +// 'Aacute': TerminalKey.aacute, +// 'Acircumflex': TerminalKey.acircumflex, +// 'Atilde': TerminalKey.atilde, +// 'Adiaeresis': TerminalKey.adiaeresis, +// 'Aring': TerminalKey.aring, +// 'AE': TerminalKey.aE, +// 'Ccedilla': TerminalKey.ccedilla, +// 'Egrave': TerminalKey.egrave, +// 'Eacute': TerminalKey.eacute, +// 'Ecircumflex': TerminalKey.ecircumflex, +// 'Ediaeresis': TerminalKey.ediaeresis, +// 'Igrave': TerminalKey.igrave, +// 'Iacute': TerminalKey.iacute, +// 'Icircumflex': TerminalKey.icircumflex, +// 'Idiaeresis': TerminalKey.idiaeresis, +// 'ETH': TerminalKey.eTH, +// 'Ntilde': TerminalKey.ntilde, +// 'Ograve': TerminalKey.ograve, +// 'Oacute': TerminalKey.oacute, +// 'Ocircumflex': TerminalKey.ocircumflex, +// 'Otilde': TerminalKey.otilde, +// 'Odiaeresis': TerminalKey.odiaeresis, +// 'multiply': TerminalKey.multiply, +// 'Ooblique': TerminalKey.ooblique, +// 'Ugrave': TerminalKey.ugrave, +// 'Uacute': TerminalKey.uacute, +// 'Ucircumflex': TerminalKey.ucircumflex, +// 'Udiaeresis': TerminalKey.udiaeresis, +// 'Yacute': TerminalKey.yacute, +// 'THORN': TerminalKey.tHORN, +// 'ssharp': TerminalKey.ssharp, +// 'division': TerminalKey.division, +// 'ydiaeresis': TerminalKey.ydiaeresis, +// 'Multi_key': TerminalKey.multi_key, +// 'Codeinput': TerminalKey.codeinput, +// 'SingleCandidate': TerminalKey.singleCandidate, +// 'MultipleCandidate': TerminalKey.multipleCandidate, +// 'PreviousCandidate': TerminalKey.previousCandidate, +// 'Mode_switch': TerminalKey.mode_switch, +// 'Kanji': TerminalKey.kanji, +// 'Muhenkan': TerminalKey.muhenkan, +// 'Henkan': TerminalKey.henkan, +// 'Romaji': TerminalKey.romaji, +// 'Hiragana': TerminalKey.hiragana, +// 'Katakana': TerminalKey.katakana, +// 'Hiragana_Katakana': TerminalKey.hiragana_Katakana, +// 'Zenkaku': TerminalKey.zenkaku, +// 'Hankaku': TerminalKey.hankaku, +// 'Zenkaku_Hankaku': TerminalKey.zenkaku_Hankaku, +// 'Touroku': TerminalKey.touroku, +// 'Massyo': TerminalKey.massyo, +// 'Kana_Lock': TerminalKey.kana_Lock, +// 'Kana_Shift': TerminalKey.kana_Shift, +// 'Eisu_Shift': TerminalKey.eisu_Shift, +// 'Eisu_toggle': TerminalKey.eisu_toggle, +// 'Hangul': TerminalKey.hangul, +// 'Hangul_Start': TerminalKey.hangul_Start, +// 'Hangul_End': TerminalKey.hangul_End, +// 'Hangul_Hanja': TerminalKey.hangul_Hanja, +// 'Hangul_Jamo': TerminalKey.hangul_Jamo, +// 'Hangul_Romaja': TerminalKey.hangul_Romaja, +// 'Hangul_Jeonja': TerminalKey.hangul_Jeonja, +// 'Hangul_Banja': TerminalKey.hangul_Banja, +// 'Hangul_PreHanja': TerminalKey.hangul_PreHanja, +// 'Hangul_PostHanja': TerminalKey.hangul_PostHanja, +// 'Hangul_Special': TerminalKey.hangul_Special, +// 'Dead_Grave': TerminalKey.dead_Grave, +// 'Dead_Acute': TerminalKey.dead_Acute, +// 'Dead_Circumflex': TerminalKey.dead_Circumflex, +// 'Dead_Tilde': TerminalKey.dead_Tilde, +// 'Dead_Macron': TerminalKey.dead_Macron, +// 'Dead_Breve': TerminalKey.dead_Breve, +// 'Dead_Abovedot': TerminalKey.dead_Abovedot, +// 'Dead_Diaeresis': TerminalKey.dead_Diaeresis, +// 'Dead_Abovering': TerminalKey.dead_Abovering, +// 'Dead_Doubleacute': TerminalKey.dead_Doubleacute, +// 'Dead_Caron': TerminalKey.dead_Caron, +// 'Dead_Cedilla': TerminalKey.dead_Cedilla, +// 'Dead_Ogonek': TerminalKey.dead_Ogonek, +// 'Dead_Iota': TerminalKey.dead_Iota, +// 'Dead_Voiced_Sound': TerminalKey.dead_Voiced_Sound, +// 'Dead_Semivoiced_Sound': TerminalKey.dead_Semivoiced_Sound, +// 'Dead_Belowdot': TerminalKey.dead_Belowdot, +// 'Dead_Hook': TerminalKey.dead_Hook, +// 'Dead_Horn': TerminalKey.dead_Horn, +// 'Dead_Stroke': TerminalKey.dead_Stroke, +// 'Dead_Abovecomma': TerminalKey.dead_Abovecomma, +// 'Dead_Abovereversedcomma': TerminalKey.dead_Abovereversedcomma, +// 'Dead_Doublegrave': TerminalKey.dead_Doublegrave, +// 'Dead_Belowring': TerminalKey.dead_Belowring, +// 'Dead_Belowmacron': TerminalKey.dead_Belowmacron, +// 'Dead_Belowcircumflex': TerminalKey.dead_Belowcircumflex, +// 'Dead_Belowtilde': TerminalKey.dead_Belowtilde, +// 'Dead_Belowbreve': TerminalKey.dead_Belowbreve, +// 'Dead_Belowdiaeresis': TerminalKey.dead_Belowdiaeresis, +// 'Dead_Invertedbreve': TerminalKey.dead_Invertedbreve, +// 'Dead_Belowcomma': TerminalKey.dead_Belowcomma, +// 'Dead_Currency': TerminalKey.dead_Currency, +// 'Dead_a': TerminalKey.dead_a, +// 'Dead_A': TerminalKey.dead_A, +// 'Dead_e': TerminalKey.dead_e, +// 'Dead_E': TerminalKey.dead_E, +// 'Dead_i': TerminalKey.dead_i, +// 'Dead_I': TerminalKey.dead_I, +// 'Dead_o': TerminalKey.dead_o, +// 'Dead_O': TerminalKey.dead_O, +// 'Dead_u': TerminalKey.dead_u, +// 'Dead_U': TerminalKey.dead_U, +// 'Dead_Small_Schwa': TerminalKey.dead_Small_Schwa, +// 'Dead_Capital_Schwa': TerminalKey.dead_Capital_Schwa, +// 'Dead_Greek': TerminalKey.dead_Greek, +// 'Dead_Lowline': TerminalKey.dead_Lowline, +// 'Dead_Aboveverticalline': TerminalKey.dead_Aboveverticalline, +// 'Dead_Belowverticalline': TerminalKey.dead_Belowverticalline, +// 'Dead_Longsolidusoverlay': TerminalKey.dead_Longsolidusoverlay, +// 'Back': TerminalKey.back, +// 'Forward': TerminalKey.forward, +// 'Stop': TerminalKey.stop, +// 'Refresh': TerminalKey.refresh, + 'VolumeDown': TerminalKey.audioVolumeDown, + 'VolumeMute': TerminalKey.audioVolumeMute, + 'VolumeUp': TerminalKey.audioVolumeUp, + 'BassBoost': TerminalKey.bassBoost, +// 'BassUp': TerminalKey.bassUp, +// 'BassDown': TerminalKey.bassDown, +// 'TrebleUp': TerminalKey.trebleUp, +// 'TrebleDown': TerminalKey.trebleDown, + 'MediaPlay': TerminalKey.mediaPlay, + 'MediaStop': TerminalKey.mediaStop, +// 'MediaPrevious': TerminalKey.mediaPrevious, +// 'MediaNext': TerminalKey.mediaNext, + 'MediaRecord': TerminalKey.mediaRecord, + 'MediaPause': TerminalKey.mediaPause, + 'MediaTogglePlayPause': TerminalKey.mediaPlayPause, + 'HomePage': TerminalKey.browserHome, +// 'Favorites': TerminalKey.favorites, +// 'Search': TerminalKey.search, +// 'Standby': TerminalKey.standby, +// 'OpenUrl': TerminalKey.openUrl, +// 'LaunchMail': TerminalKey.launchMail, +// 'LaunchMedia': TerminalKey.launchMedia, +// 'Launch0': TerminalKey.launch0, +// 'Launch1': TerminalKey.launch1, +// 'Launch2': TerminalKey.launch2, +// 'Launch3': TerminalKey.launch3, +// 'Launch4': TerminalKey.launch4, +// 'Launch5': TerminalKey.launch5, +// 'Launch6': TerminalKey.launch6, +// 'Launch7': TerminalKey.launch7, +// 'Launch8': TerminalKey.launch8, +// 'Launch9': TerminalKey.launch9, +// 'LaunchA': TerminalKey.launchA, +// 'LaunchB': TerminalKey.launchB, +// 'LaunchC': TerminalKey.launchC, +// 'LaunchD': TerminalKey.launchD, +// 'LaunchE': TerminalKey.launchE, +// 'LaunchF': TerminalKey.launchF, +// 'LaunchG': TerminalKey.launchG, +// 'LaunchH': TerminalKey.launchH, + 'MonBrightnessUp': TerminalKey.brightnessUp, + 'MonBrightnessDown': TerminalKey.brightnessDown, +// 'KeyboardLightOnOff': TerminalKey.keyboardLightOnOff, +// 'KeyboardBrightnessUp': TerminalKey.keyboardBrightnessUp, +// 'KeyboardBrightnessDown': TerminalKey.keyboardBrightnessDown, + 'PowerOff': TerminalKey.power, + 'WakeUp': TerminalKey.wakeUp, + 'Eject': TerminalKey.eject, +// 'ScreenSaver': TerminalKey.screenSaver, +// 'WWW': TerminalKey.wWW, +// 'Memo': TerminalKey.memo, +// 'LightBulb': TerminalKey.lightBulb, +// 'Shop': TerminalKey.shop, +// 'History': TerminalKey.history, +// 'AddFavorite': TerminalKey.addFavorite, +// 'HotLinks': TerminalKey.hotLinks, +// 'BrightnessAdjust': TerminalKey.brightnessAdjust, +// 'Finance': TerminalKey.finance, +// 'Community': TerminalKey.community, +// 'AudioRewind': TerminalKey.audioRewind, +// 'BackForward': TerminalKey.backForward, +// 'ApplicationLeft': TerminalKey.applicationLeft, +// 'ApplicationRight': TerminalKey.applicationRight, +// 'Book': TerminalKey.book, +// 'CD': TerminalKey.cD, +// 'Calculator': TerminalKey.calculator, +// 'ToDoList': TerminalKey.toDoList, +// 'ClearGrab': TerminalKey.clearGrab, + 'Close': TerminalKey.close, + 'Copy': TerminalKey.copy, + 'Cut': TerminalKey.cut, +// 'Display': TerminalKey.display, +// 'DOS': TerminalKey.dOS, +// 'Documents': TerminalKey.documents, +// 'Excel': TerminalKey.excel, +// 'Explorer': TerminalKey.explorer, +// 'Game': TerminalKey.game, +// 'Go': TerminalKey.go, +// 'iTouch': TerminalKey.iTouch, +// 'LogOff': TerminalKey.logOff, +// 'Market': TerminalKey.market, +// 'Meeting': TerminalKey.meeting, +// 'MenuKB': TerminalKey.menuKB, +// 'MenuPB': TerminalKey.menuPB, +// 'MySites': TerminalKey.mySites, +// 'News': TerminalKey.news, +// 'OfficeHome': TerminalKey.officeHome, +// 'Option': TerminalKey.option, +// 'Paste': TerminalKey.paste, +// 'Phone': TerminalKey.phone, +// 'Calendar': TerminalKey.calendar, +// 'Reply': TerminalKey.reply, +// 'Reload': TerminalKey.reload, +// 'RotateWindows': TerminalKey.rotateWindows, +// 'RotationPB': TerminalKey.rotationPB, +// 'RotationKB': TerminalKey.rotationKB, + 'Save': TerminalKey.save, +// 'Send': TerminalKey.send, +// 'Spell': TerminalKey.spell, +// 'SplitScreen': TerminalKey.splitScreen, +// 'Support': TerminalKey.support, +// 'TaskPane': TerminalKey.taskPane, +// 'Terminal': TerminalKey.terminal, +// 'Tools': TerminalKey.tools, +// 'Travel': TerminalKey.travel, +// 'Video': TerminalKey.video, +// 'Word': TerminalKey.word, +// 'Xfer': TerminalKey.xfer, + 'ZoomIn': TerminalKey.zoomIn, + 'ZoomOut': TerminalKey.zoomOut, +// 'Away': TerminalKey.away, +// 'Messenger': TerminalKey.messenger, +// 'WebCam': TerminalKey.webCam, +// 'MailForward': TerminalKey.mailForward, +// 'Pictures': TerminalKey.pictures, +// 'Music': TerminalKey.music, +// 'Battery': TerminalKey.battery, +// 'Bluetooth': TerminalKey.bluetooth, +// 'WLAN': TerminalKey.wLAN, +// 'UWB': TerminalKey.uWB, +// 'AudioForward': TerminalKey.audioForward, +// 'AudioRepeat': TerminalKey.audioRepeat, +// 'AudioRandomPlay': TerminalKey.audioRandomPlay, +// 'Subtitle': TerminalKey.subtitle, +// 'AudioCycleTrack': TerminalKey.audioCycleTrack, +// 'Time': TerminalKey.time, +// 'Hibernate': TerminalKey.hibernate, +// 'View': TerminalKey.view, +// 'TopMenu': TerminalKey.topMenu, +// 'PowerDown': TerminalKey.powerDown, +// 'Suspend': TerminalKey.suspend, +// 'ContrastAdjust': TerminalKey.contrastAdjust, +// 'TouchpadToggle': TerminalKey.touchpadToggle, +// 'TouchpadOn': TerminalKey.touchpadOn, +// 'TouchpadOff': TerminalKey.touchpadOff, +// 'MicMute': TerminalKey.micMute, +// 'Red': TerminalKey.red, +// 'Green': TerminalKey.green, +// 'Yellow': TerminalKey.yellow, +// 'Blue': TerminalKey.blue, + 'ChannelUp': TerminalKey.channelUp, + 'ChannelDown': TerminalKey.channelDown, +// 'Guide': TerminalKey.guide, + 'Info': TerminalKey.info, +// 'Settings': TerminalKey.settings, +// 'MicVolumeUp': TerminalKey.micVolumeUp, +// 'MicVolumeDown': TerminalKey.micVolumeDown, +// 'New': TerminalKey.new, + 'Open': TerminalKey.open, + 'Find': TerminalKey.find, + 'Undo': TerminalKey.undo, + 'Redo': TerminalKey.redo, + 'MediaLast': TerminalKey.mediaLast, +// 'unknown': TerminalKey.unknown, +// 'Call': TerminalKey.call, +// 'Camera': TerminalKey.camera, +// 'CameraFocus': TerminalKey.cameraFocus, +// 'Context1': TerminalKey.context1, +// 'Context2': TerminalKey.context2, +// 'Context3': TerminalKey.context3, +// 'Context4': TerminalKey.context4, +// 'Flip': TerminalKey.flip, +// 'Hangup': TerminalKey.hangup, +// 'No': TerminalKey.no, + 'Select': TerminalKey.select, +// 'Yes': TerminalKey.yes, +// 'ToggleCallHangup': TerminalKey.toggleCallHangup, +// 'VoiceDial': TerminalKey.voiceDial, +// 'LastNumberRedial': TerminalKey.lastNumberRedial, +// 'Execute': TerminalKey.execute, +// 'Printer': TerminalKey.printer, +// 'Play': TerminalKey.play, + 'Sleep': TerminalKey.sleep, +// 'Zoom': TerminalKey.zoom, + 'Exit': TerminalKey.exit, +// 'Cancel': TerminalKey.cancel, +}; diff --git a/lib/src/terminal/src/core/mouse/button.dart b/lib/src/terminal/src/core/mouse/button.dart new file mode 100644 index 00000000..87117a24 --- /dev/null +++ b/lib/src/terminal/src/core/mouse/button.dart @@ -0,0 +1,31 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum TerminalMouseButton { + left(id: 0), + + middle(id: 1), + + right(id: 2), + + wheelUp(id: 64 + 4, isWheel: true), + + wheelDown(id: 64 + 5, isWheel: true), + + wheelLeft(id: 64 + 6, isWheel: true), + + wheelRight(id: 64 + 7, isWheel: true), + ; + + /// The id that is used to report a button press or release to the terminal. + /// + /// Mouse wheel up / down use button IDs 4 = 0100 (binary) and 5 = 0101 (binary). + /// The bits three and four of the button are transposed by 64 and 128 + /// respectively, when reporting the id of the button and have have to be + /// adjusted correspondingly. + final int id; + + /// Whether this button is a mouse wheel button. + final bool isWheel; + + const TerminalMouseButton({required this.id, this.isWheel = false}); +} diff --git a/lib/src/terminal/src/core/mouse/button_state.dart b/lib/src/terminal/src/core/mouse/button_state.dart new file mode 100644 index 00000000..4308ed5e --- /dev/null +++ b/lib/src/terminal/src/core/mouse/button_state.dart @@ -0,0 +1,7 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum TerminalMouseButtonState { + up, + + down, +} diff --git a/lib/src/terminal/src/core/mouse/handler.dart b/lib/src/terminal/src/core/mouse/handler.dart new file mode 100644 index 00000000..9f3abddc --- /dev/null +++ b/lib/src/terminal/src/core/mouse/handler.dart @@ -0,0 +1,116 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/reporter.dart'; +import 'package:clide/src/terminal/src/core/platform.dart'; +import 'package:clide/src/terminal/src/core/state.dart'; + +class TerminalMouseEvent { + /// The button that is pressed or released. + final TerminalMouseButton button; + + /// The current state of the button. + final TerminalMouseButtonState buttonState; + + /// The position of button state change. + final CellOffset position; + + /// The state of the terminal. + final TerminalState state; + + /// The platform of the terminal. + final TerminalTargetPlatform platform; + + TerminalMouseEvent({ + required this.button, + required this.buttonState, + required this.position, + required this.state, + required this.platform, + }); +} + +const defaultMouseHandler = CascadeMouseHandler([ + ClickMouseHandler(), + UpDownMouseHandler(), +]); + +abstract class TerminalMouseHandler { + const TerminalMouseHandler(); + + String? call(TerminalMouseEvent event); +} + +class CascadeMouseHandler implements TerminalMouseHandler { + final List _handlers; + + const CascadeMouseHandler(this._handlers); + + @override + String? call(TerminalMouseEvent event) { + for (var handler in _handlers) { + final result = handler(event); + if (result != null) { + return result; + } + } + return null; + } +} + +class ClickMouseHandler implements TerminalMouseHandler { + const ClickMouseHandler(); + + @override + String? call(TerminalMouseEvent event) { + switch (event.state.mouseMode) { + case MouseMode.clickOnly: + // Only clicks and only the first 3 buttons are reported. + if (event.buttonState == TerminalMouseButtonState.down && + (event.button.id < 3)) { + return MouseReporter.report( + event.button, + event.buttonState, + event.position, + event.state.mouseReportMode, + ); + } + return null; + case MouseMode.none: + case MouseMode.upDownScroll: + case MouseMode.upDownScrollDrag: + case MouseMode.upDownScrollMove: + return null; + } + } +} + +class UpDownMouseHandler implements TerminalMouseHandler { + const UpDownMouseHandler(); + + @override + String? call(TerminalMouseEvent event) { + switch (event.state.mouseMode) { + case MouseMode.none: + case MouseMode.clickOnly: + return null; + case MouseMode.upDownScroll: + case MouseMode.upDownScrollDrag: + case MouseMode.upDownScrollMove: + // Up events are never reported for mouse wheel buttons. + if (event.button.isWheel && + event.buttonState == TerminalMouseButtonState.up) { + return null; + } + return MouseReporter.report( + event.button, + event.buttonState, + event.position, + event.state.mouseReportMode, + ); + } + } +} diff --git a/lib/src/terminal/src/core/mouse/mode.dart b/lib/src/terminal/src/core/mouse/mode.dart new file mode 100644 index 00000000..622cab25 --- /dev/null +++ b/lib/src/terminal/src/core/mouse/mode.dart @@ -0,0 +1,38 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +/// https://terminalguide.namepad.de/mouse/ +enum MouseMode { + none, + + clickOnly, + + upDownScroll(reportScroll: true), + + upDownScrollDrag(reportScroll: true), + + upDownScrollMove(reportScroll: true), + ; + + const MouseMode({this.reportScroll = false}); + + final bool reportScroll; +} + +/// https://terminalguide.namepad.de/mouse/ +enum MouseReportMode { + /// The default mouse reporting mode where digits are encoded as bytes with + /// `32 + code`. This mode has a range from 1 to 223. + normal, + + /// When code < 96 this is the same as [normal], otherwise the `code + 32` is + /// encoded as 2 bytes in UTF-8. This mode has a range from 1 to 2015. + utf, + + /// In this mode the code are encoded as 10-based numbers. Tha range is + /// unlimited. + sgr, + + /// Similar to [sgr], the difference is that the button id is encoded as + /// `32 + code`. + urxvt, +} diff --git a/lib/src/terminal/src/core/mouse/reporter.dart b/lib/src/terminal/src/core/mouse/reporter.dart new file mode 100644 index 00000000..0cf5c981 --- /dev/null +++ b/lib/src/terminal/src/core/mouse/reporter.dart @@ -0,0 +1,50 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; + +abstract class MouseReporter { + static String report( + TerminalMouseButton button, + TerminalMouseButtonState state, + CellOffset position, + MouseReportMode reportMode, + ) { + // x and y offsets have to be incremented by 1 as the offset if 0-based, + // The position has to be reported using 1-based coordinates. + final x = position.x + 1; + final y = position.y + 1; + switch (reportMode) { + case MouseReportMode.normal: + case MouseReportMode.utf: + // Button ID 3 is used to signal a button release. + final buttonID = state == TerminalMouseButtonState.up ? 3 : button.id; + // The button ID is reported as shifted by 32 to produce a printable + // character. + final btn = String.fromCharCode(32 + buttonID); + // Normal mode only supports a maximum position of 223, while utf + // supports positions up to 2015. Both modes send a null byte if the + // position exceeds that limit. + final col = (reportMode == MouseReportMode.normal && x > 223) || + (reportMode == MouseReportMode.utf && x > 2015) + ? '\x00' + : String.fromCharCode(32 + x); + final row = (reportMode == MouseReportMode.normal && y > 223) || + (reportMode == MouseReportMode.utf && y > 2015) + ? '\x00' + : String.fromCharCode(32 + y + 1); + return "\x1b[M$btn$col$row"; + case MouseReportMode.sgr: + final buttonID = button.id; + final upDown = state == TerminalMouseButtonState.down ? 'M' : 'm'; + return "\x1b[<$buttonID;$x;$y$upDown"; + case MouseReportMode.urxvt: + // The button ID uses the same id as to report it as in normal mode. + final buttonID = + 32 + (state == TerminalMouseButtonState.up ? 3 : button.id); + return "\x1b[$buttonID;$x;${y}M"; + } + } +} diff --git a/lib/src/terminal/src/core/platform.dart b/lib/src/terminal/src/core/platform.dart new file mode 100644 index 00000000..d30f691b --- /dev/null +++ b/lib/src/terminal/src/core/platform.dart @@ -0,0 +1,19 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum TerminalTargetPlatform { + unknown, + + android, + + ios, + + fuchsia, + + linux, + + macos, + + windows, + + web, +} diff --git a/lib/src/terminal/src/core/reflow.dart b/lib/src/terminal/src/core/reflow.dart new file mode 100644 index 00000000..05451458 --- /dev/null +++ b/lib/src/terminal/src/core/reflow.dart @@ -0,0 +1,199 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/buffer/line.dart'; +import 'package:clide/src/terminal/src/utils/circular_buffer.dart'; + +class _LineBuilder { + _LineBuilder([this._capacity = 80]) { + _result = BufferLine(_capacity); + } + + final int _capacity; + + late BufferLine _result; + + int _length = 0; + + int get length => _length; + + bool get isEmpty => _length == 0; + + bool get isNotEmpty => _length != 0; + + /// Adds a range of cells from [src] to the builder. Anchors within the range + /// will be reparented to the new line returned by [take]. + void add(BufferLine src, int start, int length) { + _result.copyFrom(src, start, _length, length); + _length += length; + } + + /// Reuses the given [line] as the initial buffer for this builder. + void setBuffer(BufferLine line, int length) { + _result = line; + _length = length; + } + + void addAnchor(CellAnchor anchor, int offset) { + anchor.reparent(_result, _length + offset); + } + + BufferLine take({required bool wrapped}) { + final result = _result; + result.isWrapped = wrapped; + // result.resize(_length); + + _result = BufferLine(_capacity); + _length = 0; + + return result; + } +} + +/// Holds a the state of reflow operation of a single logical line. +class _LineReflow { + final int oldWidth; + + final int newWidth; + + _LineReflow(this.oldWidth, this.newWidth); + + final _lines = []; + + late final _builder = _LineBuilder(newWidth); + + /// Adds a line to the reflow operation. This method will try to reuse the + /// given line if possible. + void add(BufferLine line) { + final trimmedLength = line.getTrimmedLength(oldWidth); + + // A fast path for empty lines + if (trimmedLength == 0) { + _lines.add(line); + return; + } + + // We already have some content in the buffer, so we copy the content into + // the builder instead of reusing the line. + if (_lines.isNotEmpty || _builder.isNotEmpty) { + _addPart(line, from: 0, to: trimmedLength); + return; + } + + if (newWidth >= oldWidth) { + // Reuse the line to avoid copying the content and object allocation. + _builder.setBuffer(line, trimmedLength); + } else { + _lines.add(line); + + if (trimmedLength > newWidth) { + if (line.getWidth(newWidth - 1) == 2) { + _addPart(line, from: newWidth - 1, to: trimmedLength); + } else { + _addPart(line, from: newWidth, to: trimmedLength); + } + } + } + + line.resize(newWidth); + + if (line.getWidth(newWidth - 1) == 2) { + line.resetCell(newWidth - 1); + } + } + + /// Adds part of [line] from [from] to [to] to the reflow operation. + /// Anchors within the range will be removed from [line] and reparented to + /// the new line(s) returned by [finish]. + void _addPart(BufferLine line, {required int from, required int to}) { + var cellsLeft = to - from; + + while (cellsLeft > 0) { + final bufferRemainingCells = newWidth - _builder.length; + + // How many cells we should copy in this iteration. + var cellsToCopy = cellsLeft; + + // Whether the buffer is filled up in this iteration. + var lineFilled = false; + + if (cellsToCopy >= bufferRemainingCells) { + cellsToCopy = bufferRemainingCells; + lineFilled = true; + } + + // Leave the last cell to the next iteration if it's a wide char. + if (lineFilled && line.getWidth(from + cellsToCopy - 1) == 2) { + cellsToCopy--; + } + + for (var anchor in line.anchors.toList()) { + if (anchor.x >= from && anchor.x <= from + cellsToCopy) { + _builder.addAnchor(anchor, anchor.x - from); + } + } + + _builder.add(line, from, cellsToCopy); + + from += cellsToCopy; + cellsLeft -= cellsToCopy; + + // Create a new line if the buffer is filled up. + if (lineFilled) { + _lines.add(_builder.take(wrapped: _lines.isNotEmpty)); + } + } + + if (line.anchors.isNotEmpty) { + for (var anchor in line.anchors.toList()) { + if (anchor.x >= to) { + _builder.addAnchor(anchor, anchor.x - to); + } + } + } + } + + /// Finalizes the reflow operation and returns the result. + List finish() { + if (_builder.isNotEmpty) { + _lines.add(_builder.take(wrapped: _lines.isNotEmpty)); + } + + return _lines; + } +} + +List reflow( + IndexAwareCircularBuffer lines, + int oldWidth, + int newWidth, +) { + final result = []; + + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + + final reflow = _LineReflow(oldWidth, newWidth); + + reflow.add(line); + + for (var offset = i + 1; offset < lines.length; offset++) { + final nextLine = lines[offset]; + + if (!nextLine.isWrapped) { + break; + } + + i++; + + reflow.add(nextLine); + } + + result.addAll(reflow.finish()); + } + + for (var line in result) { + line.resize(newWidth); + } + + return result; +} diff --git a/lib/src/terminal/src/core/snapshot.dart b/lib/src/terminal/src/core/snapshot.dart new file mode 100644 index 00000000..3b18cd33 --- /dev/null +++ b/lib/src/terminal/src/core/snapshot.dart @@ -0,0 +1,5 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +abstract class TerminalSnapshot { + void trimScrollback(); +} diff --git a/lib/src/terminal/src/core/state.dart b/lib/src/terminal/src/core/state.dart new file mode 100644 index 00000000..74e5e0ac --- /dev/null +++ b/lib/src/terminal/src/core/state.dart @@ -0,0 +1,46 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:clide/src/terminal/src/core/cursor.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; + +abstract class TerminalState { + int get viewWidth; + + int get viewHeight; + + CursorStyle get cursor; + + bool get reflowEnabled; + + /* Modes */ + + bool get insertMode; + + bool get lineFeedMode; + + /* DEC Private modes */ + + bool get cursorKeysMode; + + bool get reverseDisplayMode; + + bool get originMode; + + bool get autoWrapMode; + + MouseMode get mouseMode; + + MouseReportMode get mouseReportMode; + + bool get cursorBlinkMode; + + bool get cursorVisibleMode; + + bool get appKeypadMode; + + bool get reportFocusMode; + + bool get altBufferMouseScrollMode; + + bool get bracketedPasteMode; +} diff --git a/lib/src/terminal/src/core/tabs.dart b/lib/src/terminal/src/core/tabs.dart new file mode 100644 index 00000000..c5b19d11 --- /dev/null +++ b/lib/src/terminal/src/core/tabs.dart @@ -0,0 +1,70 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show min; + +const _kMaxColumns = 1024; + +/// Manages the tab stop state for a terminal. +class TabStops { + final _stops = List.filled(_kMaxColumns, false); + + TabStops() { + _initialize(); + } + + /// Initializes the tab stops to the default 8 column intervals. + void _initialize() { + const interval = 8; + for (var i = 0; i < _kMaxColumns; i += interval) { + _stops[i] = true; + } + } + + /// Finds the next tab stop index, which satisfies [start] <= index < [end]. + int? find(int start, int end) { + if (start >= end) { + return null; + } + end = min(end, _stops.length); + for (var i = start; i < end; i++) { + if (_stops[i]) { + return i; + } + } + return null; + } + + /// Sets the tab stop at [index]. If there is already a tab stop at [index], + /// this method does nothing. + /// + /// See also: + /// * [clearAt] which does the opposite. + void setAt(int index) { + assert(index >= 0 && index < _kMaxColumns); + _stops[index] = true; + } + + /// Clears the tab stop at [index]. If there is no tab stop at [index], this + /// method does nothing. + void clearAt(int index) { + assert(index >= 0 && index < _kMaxColumns); + _stops[index] = false; + } + + /// Clears all tab stops without resetting them to the default 8 column + /// intervals. + void clearAll() { + _stops.fillRange(0, _kMaxColumns, false); + } + + /// Returns true if there is a tab stop at [index]. + bool isSetAt(int index) { + return _stops[index]; + } + + /// Resets the tab stops to the default 8 column intervals. + void reset() { + clearAll(); + _initialize(); + } +} diff --git a/lib/src/terminal/src/terminal.dart b/lib/src/terminal/src/terminal.dart new file mode 100644 index 00000000..3839553e --- /dev/null +++ b/lib/src/terminal/src/terminal.dart @@ -0,0 +1,908 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show max; + +import 'package:clide/src/terminal/src/base/observable.dart'; +import 'package:clide/src/terminal/src/core/buffer/buffer.dart'; +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/line.dart'; +import 'package:clide/src/terminal/src/core/cursor.dart'; +import 'package:clide/src/terminal/src/core/escape/emitter.dart'; +import 'package:clide/src/terminal/src/core/escape/handler.dart'; +import 'package:clide/src/terminal/src/core/escape/parser.dart'; +import 'package:clide/src/terminal/src/core/input/handler.dart'; +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; +import 'package:clide/src/terminal/src/core/mouse/handler.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; +import 'package:clide/src/terminal/src/core/platform.dart'; +import 'package:clide/src/terminal/src/core/state.dart'; +import 'package:clide/src/terminal/src/core/tabs.dart'; +import 'package:clide/src/terminal/src/utils/ascii.dart'; +import 'package:clide/src/terminal/src/utils/circular_buffer.dart'; + +/// [Terminal] is an interface to interact with command line applications. It +/// translates escape sequences from the application into updates to the +/// [buffer] and events such as [onTitleChange] or [onBell], as well as +/// translating user input into escape sequences that the application can +/// understand. +class Terminal with Observable implements TerminalState, EscapeHandler { + /// The number of lines that the scrollback buffer can hold. If the buffer + /// exceeds this size, the lines at the top of the buffer will be removed. + final int maxLines; + + /// Function that is called when the program requests the terminal to ring + /// the bell. If not set, the terminal will do nothing. + void Function()? onBell; + + /// Function that is called when the program requests the terminal to change + /// the title of the window to [title]. + void Function(String title)? onTitleChange; + + /// Function that is called when the program requests the terminal to change + /// the icon of the window. [icon] is the name of the icon. + void Function(String icon)? onIconChange; + + /// Function that is called when the terminal emits data to the underlying + /// program. This is typically caused by user inputs from [textInput], + /// [keyInput], [mouseInput], or [paste]. + void Function(String data)? onOutput; + + /// Function that is called when the dimensions of the terminal change. + void Function(int width, int height, int pixelWidth, int pixelHeight)? + onResize; + + /// The [TerminalInputHandler] used by this terminal. [defaultInputHandler] is + /// used when not specified. User of this class can provide their own + /// implementation of [TerminalInputHandler] or extend [defaultInputHandler] + /// with [CascadeInputHandler]. + TerminalInputHandler? inputHandler; + + TerminalMouseHandler? mouseHandler; + + /// The callback that is called when the terminal receives a unrecognized + /// escape sequence. + void Function(String code, List args)? onPrivateOSC; + + /// Flag to toggle os specific behaviors. + final TerminalTargetPlatform platform; + + /// Characters that break selection when double clicking. If not set, the + /// [Buffer.defaultWordSeparators] will be used. + final Set? wordSeparators; + + Terminal({ + this.maxLines = 1000, + this.onBell, + this.onTitleChange, + this.onIconChange, + this.onOutput, + this.onResize, + this.platform = TerminalTargetPlatform.unknown, + this.inputHandler = defaultInputHandler, + this.mouseHandler = defaultMouseHandler, + this.onPrivateOSC, + this.reflowEnabled = true, + this.wordSeparators, + }); + + late final _parser = EscapeParser(this); + + final _emitter = const EscapeEmitter(); + + late var _buffer = _mainBuffer; + + late final _mainBuffer = Buffer( + this, + maxLines: maxLines, + isAltBuffer: false, + wordSeparators: wordSeparators, + ); + + late final _altBuffer = Buffer( + this, + maxLines: maxLines, + isAltBuffer: true, + wordSeparators: wordSeparators, + ); + + final _tabStops = TabStops(); + + /// The last character written to the buffer. Used to implement some escape + /// sequences that repeat the last character. + var _precedingCodepoint = 0; + + /* TerminalState */ + + int _viewWidth = 80; + + int _viewHeight = 24; + + final _cursorStyle = CursorStyle(); + + bool _insertMode = false; + + bool _lineFeedMode = false; + + bool _cursorKeysMode = false; + + bool _reverseDisplayMode = false; + + bool _originMode = false; + + bool _autoWrapMode = true; + + MouseMode _mouseMode = MouseMode.none; + + MouseReportMode _mouseReportMode = MouseReportMode.normal; + + bool _cursorBlinkMode = false; + + bool _cursorVisibleMode = true; + + bool _appKeypadMode = false; + + bool _reportFocusMode = false; + + bool _altBufferMouseScrollMode = false; + + bool _bracketedPasteMode = false; + + /* State getters */ + + /// Number of cells in a terminal row. + @override + int get viewWidth => _viewWidth; + + /// Number of rows in this terminal. + @override + int get viewHeight => _viewHeight; + + @override + CursorStyle get cursor => _cursorStyle; + + @override + bool get insertMode => _insertMode; + + @override + bool get lineFeedMode => _lineFeedMode; + + @override + bool get cursorKeysMode => _cursorKeysMode; + + @override + bool get reverseDisplayMode => _reverseDisplayMode; + + @override + bool get originMode => _originMode; + + @override + bool get autoWrapMode => _autoWrapMode; + + @override + MouseMode get mouseMode => _mouseMode; + + @override + MouseReportMode get mouseReportMode => _mouseReportMode; + + @override + bool get cursorBlinkMode => _cursorBlinkMode; + + @override + bool get cursorVisibleMode => _cursorVisibleMode; + + @override + bool get appKeypadMode => _appKeypadMode; + + @override + bool get reportFocusMode => _reportFocusMode; + + @override + bool get altBufferMouseScrollMode => _altBufferMouseScrollMode; + + @override + bool get bracketedPasteMode => _bracketedPasteMode; + + /// Current active buffer of the terminal. This is initially [mainBuffer] and + /// can be switched back and forth from [altBuffer] to [mainBuffer] when + /// the underlying program requests it. + Buffer get buffer => _buffer; + + Buffer get mainBuffer => _mainBuffer; + + Buffer get altBuffer => _altBuffer; + + bool get isUsingAltBuffer => _buffer == _altBuffer; + + /// Lines of the active buffer. + IndexAwareCircularBuffer get lines => _buffer.lines; + + /// Whether the terminal performs reflow when the viewport size changes or + /// simply truncates lines. true by default. + @override + bool reflowEnabled; + + /// Writes the data from the underlying program to the terminal. Calling this + /// updates the states of the terminal and emits events such as [onBell] or + /// [onTitleChange] when the escape sequences in [data] request it. + void write(String data) { + _parser.write(data); + notifyListeners(); + } + + /// Sends a key event to the underlying program. + /// + /// See also: + /// - [charInput] + /// - [textInput] + /// - [paste] + bool keyInput( + TerminalKey key, { + bool shift = false, + bool alt = false, + bool ctrl = false, + }) { + final output = inputHandler?.call( + TerminalKeyboardEvent( + key: key, + shift: shift, + alt: alt, + ctrl: ctrl, + state: this, + altBuffer: isUsingAltBuffer, + platform: platform, + ), + ); + + if (output != null) { + onOutput?.call(output); + return true; + } + + return false; + } + + /// Similary to [keyInput], but takes a character as input instead of a + /// [TerminalKey]. + /// + /// See also: + /// - [keyInput] + /// - [textInput] + /// - [paste] + bool charInput( + int charCode, { + bool alt = false, + bool ctrl = false, + }) { + if (ctrl) { + // a(97) ~ z(122) + if (charCode >= Ascii.a && charCode <= Ascii.z) { + final output = charCode - Ascii.a + 1; + onOutput?.call(String.fromCharCode(output)); + return true; + } + + // [(91) ~ _(95) + if (charCode >= Ascii.openBracket && charCode <= Ascii.underscore) { + final output = charCode - Ascii.openBracket + 27; + onOutput?.call(String.fromCharCode(output)); + return true; + } + } + + if (alt && platform != TerminalTargetPlatform.macos) { + if (charCode >= Ascii.a && charCode <= Ascii.z) { + final code = charCode - Ascii.a + 65; + final input = [0x1b, code]; + onOutput?.call(String.fromCharCodes(input)); + return true; + } + } + + return false; + } + + /// Sends regular text input to the underlying program. + /// + /// See also: + /// - [keyInput] + /// - [charInput] + /// - [paste] + void textInput(String text) { + onOutput?.call(text); + } + + /// Similar to [textInput], except that when the program tells the terminal + /// that it supports [bracketedPasteMode], the text is wrapped in escape + /// sequences to indicate that it is a paste operation. Prefer this method + /// over [textInput] when pasting text. + /// + /// See also: + /// - [textInput] + void paste(String text) { + if (_bracketedPasteMode) { + onOutput?.call(_emitter.bracketedPaste(text)); + } else { + textInput(text); + } + } + + // Handle a mouse event and return true if it was handled. + bool mouseInput( + TerminalMouseButton button, + TerminalMouseButtonState buttonState, + CellOffset position, + ) { + final output = mouseHandler?.call(TerminalMouseEvent( + button: button, + buttonState: buttonState, + position: position, + state: this, + platform: platform, + )); + if (output != null) { + onOutput?.call(output); + return true; + } + return false; + } + + /// Resize the terminal screen. [newWidth] and [newHeight] should be greater + /// than 0. Text reflow is currently not implemented and will be avaliable in + /// the future. + @override + void resize( + int newWidth, + int newHeight, [ + int? pixelWidth, + int? pixelHeight, + ]) { + newWidth = max(newWidth, 1); + newHeight = max(newHeight, 1); + + onResize?.call(newWidth, newHeight, pixelWidth ?? 0, pixelHeight ?? 0); + + //we need to resize both buffers so that they are ready when we switch between them + _altBuffer.resize(_viewWidth, _viewHeight, newWidth, newHeight); + _mainBuffer.resize(_viewWidth, _viewHeight, newWidth, newHeight); + + _viewWidth = newWidth; + _viewHeight = newHeight; + + if (buffer == _altBuffer) { + buffer.clearScrollback(); + } + + _altBuffer.resetVerticalMargins(); + _mainBuffer.resetVerticalMargins(); + } + + @override + String toString() { + return 'Terminal(#$hashCode, $_viewWidth x $_viewHeight, ${_buffer.height} lines)'; + } + + /* Handlers */ + + @override + void writeChar(int char) { + _precedingCodepoint = char; + _buffer.writeChar(char); + } + + /* SBC */ + + @override + void bell() { + onBell?.call(); + } + + @override + void backspaceReturn() { + _buffer.moveCursorX(-1); + } + + @override + void tab() { + final nextStop = _tabStops.find(_buffer.cursorX + 1, _viewWidth); + + if (nextStop != null) { + _buffer.setCursorX(nextStop); + } else { + _buffer.setCursorX(_viewWidth); + _buffer.cursorGoForward(); // Enter pending-wrap state + } + } + + @override + void lineFeed() { + _buffer.lineFeed(); + } + + @override + void carriageReturn() { + _buffer.setCursorX(0); + } + + @override + void shiftOut() { + _buffer.charset.use(1); + } + + @override + void shiftIn() { + _buffer.charset.use(0); + } + + @override + void unknownSBC(int char) { + // no-op + } + + /* ANSI sequence */ + + @override + void saveCursor() { + _buffer.saveCursor(); + } + + @override + void restoreCursor() { + _buffer.restoreCursor(); + } + + @override + void index() { + _buffer.index(); + } + + @override + void nextLine() { + _buffer.index(); + _buffer.setCursorX(0); + } + + @override + void setTapStop() { + _tabStops.isSetAt(_buffer.cursorX); + } + + @override + void reverseIndex() { + _buffer.reverseIndex(); + } + + @override + void designateCharset(int charset, int name) { + _buffer.charset.designate(charset, name); + } + + @override + void unkownEscape(int char) { + // no-op + } + + /* CSI */ + + @override + void repeatPreviousCharacter(int count) { + if (_precedingCodepoint == 0) { + return; + } + + for (var i = 0; i < count; i++) { + _buffer.writeChar(_precedingCodepoint); + } + } + + @override + void setCursor(int x, int y) { + _buffer.setCursor(x, y); + } + + @override + void setCursorX(int x) { + _buffer.setCursorX(x); + } + + @override + void setCursorY(int y) { + _buffer.setCursorY(y); + } + + @override + void moveCursorX(int offset) { + _buffer.moveCursorX(offset); + } + + @override + void moveCursorY(int n) { + _buffer.moveCursorY(n); + } + + @override + void clearTabStopUnderCursor() { + _tabStops.clearAt(_buffer.cursorX); + } + + @override + void clearAllTabStops() { + _tabStops.clearAll(); + } + + @override + void sendPrimaryDeviceAttributes() { + onOutput?.call(_emitter.primaryDeviceAttributes()); + } + + @override + void sendSecondaryDeviceAttributes() { + onOutput?.call(_emitter.secondaryDeviceAttributes()); + } + + @override + void sendTertiaryDeviceAttributes() { + onOutput?.call(_emitter.tertiaryDeviceAttributes()); + } + + @override + void sendOperatingStatus() { + onOutput?.call(_emitter.operatingStatus()); + } + + @override + void sendCursorPosition() { + onOutput?.call(_emitter.cursorPosition(_buffer.cursorX, _buffer.cursorY)); + } + + @override + void setMargins(int top, [int? bottom]) { + _buffer.setVerticalMargins(top, bottom ?? viewHeight - 1); + } + + @override + void cursorNextLine(int amount) { + _buffer.moveCursorY(amount); + _buffer.setCursorX(0); + } + + @override + void cursorPrecedingLine(int amount) { + _buffer.moveCursorY(-amount); + _buffer.setCursorX(0); + } + + @override + void eraseDisplayBelow() { + _buffer.eraseDisplayFromCursor(); + } + + @override + void eraseDisplayAbove() { + _buffer.eraseDisplayToCursor(); + } + + @override + void eraseDisplay() { + _buffer.eraseDisplay(); + } + + @override + void eraseScrollbackOnly() { + _buffer.clearScrollback(); + } + + @override + void eraseLineRight() { + _buffer.eraseLineFromCursor(); + } + + @override + void eraseLineLeft() { + _buffer.eraseLineToCursor(); + } + + @override + void eraseLine() { + _buffer.eraseLine(); + } + + @override + void insertLines(int amount) { + _buffer.insertLines(amount); + } + + @override + void deleteLines(int amount) { + _buffer.deleteLines(amount); + } + + @override + void deleteChars(int amount) { + _buffer.deleteChars(amount); + } + + @override + void scrollUp(int amount) { + _buffer.scrollUp(amount); + } + + @override + void scrollDown(int amount) { + _buffer.scrollDown(amount); + } + + @override + void eraseChars(int amount) { + _buffer.eraseChars(amount); + } + + @override + void insertBlankChars(int amount) { + _buffer.insertBlankChars(amount); + } + + @override + void sendSize() { + onOutput?.call(_emitter.size(viewHeight, viewWidth)); + } + + @override + void unknownCSI(int finalByte) { + // no-op + } + + /* Modes */ + + @override + void setInsertMode(bool enabled) { + _insertMode = enabled; + } + + @override + void setLineFeedMode(bool enabled) { + _lineFeedMode = enabled; + } + + @override + void setUnknownMode(int mode, bool enabled) { + // no-op + } + + /* DEC Private modes */ + + @override + void setCursorKeysMode(bool enabled) { + _cursorKeysMode = enabled; + } + + @override + void setReverseDisplayMode(bool enabled) { + _reverseDisplayMode = enabled; + } + + @override + void setOriginMode(bool enabled) { + _originMode = enabled; + } + + @override + void setColumnMode(bool enabled) { + // no-op + } + + @override + void setAutoWrapMode(bool enabled) { + _autoWrapMode = enabled; + } + + @override + void setMouseMode(MouseMode mode) { + _mouseMode = mode; + } + + @override + void setCursorBlinkMode(bool enabled) { + _cursorBlinkMode = enabled; + } + + @override + void setCursorVisibleMode(bool enabled) { + _cursorVisibleMode = enabled; + } + + @override + void useAltBuffer() { + _buffer = _altBuffer; + } + + @override + void useMainBuffer() { + _buffer = _mainBuffer; + } + + @override + void clearAltBuffer() { + _altBuffer.clear(); + } + + @override + void setAppKeypadMode(bool enabled) { + _appKeypadMode = enabled; + } + + @override + void setReportFocusMode(bool enabled) { + _reportFocusMode = enabled; + } + + @override + void setMouseReportMode(MouseReportMode mode) { + _mouseReportMode = mode; + } + + @override + void setAltBufferMouseScrollMode(bool enabled) { + _altBufferMouseScrollMode = enabled; + } + + @override + void setBracketedPasteMode(bool enabled) { + _bracketedPasteMode = enabled; + } + + @override + void setUnknownDecMode(int mode, bool enabled) { + // no-op + } + + /* Select Graphic Rendition (SGR) */ + + @override + void resetCursorStyle() { + _cursorStyle.reset(); + } + + @override + void setCursorBold() { + _cursorStyle.setBold(); + } + + @override + void setCursorFaint() { + _cursorStyle.setFaint(); + } + + @override + void setCursorItalic() { + _cursorStyle.setItalic(); + } + + @override + void setCursorUnderline() { + _cursorStyle.setUnderline(); + } + + @override + void setCursorBlink() { + _cursorStyle.setBlink(); + } + + @override + void setCursorInverse() { + _cursorStyle.setInverse(); + } + + @override + void setCursorInvisible() { + _cursorStyle.setInvisible(); + } + + @override + void setCursorStrikethrough() { + _cursorStyle.setStrikethrough(); + } + + @override + void unsetCursorBold() { + _cursorStyle.unsetBold(); + } + + @override + void unsetCursorFaint() { + _cursorStyle.unsetFaint(); + } + + @override + void unsetCursorItalic() { + _cursorStyle.unsetItalic(); + } + + @override + void unsetCursorUnderline() { + _cursorStyle.unsetUnderline(); + } + + @override + void unsetCursorBlink() { + _cursorStyle.unsetBlink(); + } + + @override + void unsetCursorInverse() { + _cursorStyle.unsetInverse(); + } + + @override + void unsetCursorInvisible() { + _cursorStyle.unsetInvisible(); + } + + @override + void unsetCursorStrikethrough() { + _cursorStyle.unsetStrikethrough(); + } + + @override + void setForegroundColor16(int color) { + _cursorStyle.setForegroundColor16(color); + } + + @override + void setForegroundColor256(int index) { + _cursorStyle.setForegroundColor256(index); + } + + @override + void setForegroundColorRgb(int r, int g, int b) { + _cursorStyle.setForegroundColorRgb(r, g, b); + } + + @override + void resetForeground() { + _cursorStyle.resetForegroundColor(); + } + + @override + void setBackgroundColor16(int color) { + _cursorStyle.setBackgroundColor16(color); + } + + @override + void setBackgroundColor256(int index) { + _cursorStyle.setBackgroundColor256(index); + } + + @override + void setBackgroundColorRgb(int r, int g, int b) { + _cursorStyle.setBackgroundColorRgb(r, g, b); + } + + @override + void resetBackground() { + _cursorStyle.resetBackgroundColor(); + } + + @override + void unsupportedStyle(int param) { + // no-op + } + + /* OSC */ + + @override + void setTitle(String name) { + onTitleChange?.call(name); + } + + @override + void setIconName(String name) { + onIconChange?.call(name); + } + + @override + void unknownOSC(String ps, List pt) { + onPrivateOSC?.call(ps, pt); + } +} diff --git a/lib/src/terminal/src/terminal_view.dart b/lib/src/terminal/src/terminal_view.dart new file mode 100644 index 00000000..f1b0f2f7 --- /dev/null +++ b/lib/src/terminal/src/terminal_view.dart @@ -0,0 +1,545 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; + +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; +import 'package:clide/src/terminal/src/terminal.dart'; +import 'package:clide/src/terminal/src/ui/controller.dart'; +import 'package:clide/src/terminal/src/ui/cursor_type.dart'; +import 'package:clide/src/terminal/src/ui/custom_text_edit.dart'; +import 'package:clide/src/terminal/src/ui/gesture/gesture_handler.dart'; +import 'package:clide/src/terminal/src/ui/input_map.dart'; +import 'package:clide/src/terminal/src/ui/keyboard_listener.dart'; +import 'package:clide/src/terminal/src/ui/keyboard_visibility.dart'; +import 'package:clide/src/terminal/src/ui/render.dart'; +import 'package:clide/src/terminal/src/ui/shortcut/actions.dart'; +import 'package:clide/src/terminal/src/ui/shortcut/shortcuts.dart'; +import 'package:clide/src/terminal/src/ui/terminal_text_style.dart'; +import 'package:clide/src/terminal/src/ui/terminal_theme.dart'; +import 'package:clide/src/terminal/src/ui/themes.dart'; + +class TerminalView extends StatefulWidget { + const TerminalView( + this.terminal, { + super.key, + this.controller, + this.theme = TerminalThemes.defaultTheme, + this.textStyle = const TerminalStyle(), + this.textScaler, + this.padding, + this.scrollController, + this.autoResize = true, + this.backgroundOpacity = 1, + this.focusNode, + this.autofocus = false, + this.onTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.mouseCursor = SystemMouseCursors.text, + this.keyboardType = TextInputType.emailAddress, + this.keyboardAppearance = Brightness.dark, + this.cursorType = TerminalCursorType.block, + this.alwaysShowCursor = false, + this.deleteDetection = false, + this.shortcuts, + this.onKeyEvent, + this.readOnly = false, + this.hardwareKeyboardOnly = false, + this.simulateScroll = true, + }); + + /// The underlying terminal that this widget renders. + final Terminal terminal; + + final TerminalController? controller; + + /// The theme to use for this terminal. + final TerminalTheme theme; + + /// The style to use for painting characters. + final TerminalStyle textStyle; + + final TextScaler? textScaler; + + /// Padding around the inner [Scrollable] widget. + final EdgeInsets? padding; + + /// Scroll controller for the inner [Scrollable] widget. + final ScrollController? scrollController; + + /// Should this widget automatically notify the underlying terminal when its + /// size changes. [true] by default. + final bool autoResize; + + /// Opacity of the terminal background. Set to 0 to make the terminal + /// background transparent. + final double backgroundOpacity; + + /// An optional focus node to use as the focus node for this widget. + final FocusNode? focusNode; + + /// True if this widget will be selected as the initial focus when no other + /// node in its scope is currently focused. + final bool autofocus; + + /// Callback for when the user taps on the terminal. + final void Function(TapUpDetails, CellOffset)? onTapUp; + + /// Function called when the user taps on the terminal with a secondary + /// button. + final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown; + + /// Function called when the user stops holding down a secondary button. + final void Function(TapUpDetails, CellOffset)? onSecondaryTapUp; + + /// The mouse cursor for mouse pointers that are hovering over the terminal. + /// [SystemMouseCursors.text] by default. + final MouseCursor mouseCursor; + + /// The type of information for which to optimize the text input control. + /// [TextInputType.emailAddress] by default. + final TextInputType keyboardType; + + /// The appearance of the keyboard. [Brightness.dark] by default. + /// + /// This setting is only honored on iOS devices. + final Brightness keyboardAppearance; + + /// The type of cursor to use. [TerminalCursorType.block] by default. + final TerminalCursorType cursorType; + + /// Whether to always show the cursor. This is useful for debugging. + /// [false] by default. + final bool alwaysShowCursor; + + /// Workaround to detect delete key for platforms and IMEs that does not + /// emit hardware delete event. Prefered on mobile platforms. [false] by + /// default. + final bool deleteDetection; + + /// Shortcuts for this terminal. This has higher priority than input handler + /// of the terminal If not provided, [defaultTerminalShortcuts] will be used. + final Map? shortcuts; + + /// Keyboard event handler of the terminal. This has higher priority than + /// [shortcuts] and input handler of the terminal. + final FocusOnKeyEventCallback? onKeyEvent; + + /// True if no input should send to the terminal. + final bool readOnly; + + /// True if only hardware keyboard events should be used as input. This will + /// also prevent any on-screen keyboard to be shown. + final bool hardwareKeyboardOnly; + + /// If true, when the terminal is in alternate buffer (for example running + /// vim, man, etc), if the application does not declare that it can handle + /// scrolling, the terminal will simulate scrolling by sending up/down arrow + /// keys to the application. This is standard behavior for most terminal + /// emulators. True by default. + final bool simulateScroll; + + @override + State createState() => TerminalViewState(); +} + +class TerminalViewState extends State { + late FocusNode _focusNode; + + late final ShortcutManager _shortcutManager; + + final _customTextEditKey = GlobalKey(); + + final _scrollableKey = GlobalKey(); + + final _viewportKey = GlobalKey(); + + String? _composingText; + + late TerminalController _controller; + + late ScrollController _scrollController; + + RenderTerminal get renderTerminal => + _viewportKey.currentContext!.findRenderObject() as RenderTerminal; + + void _onPointerSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent) return; + final lh = renderTerminal.lineHeight; + if (lh <= 0) return; + final position = renderTerminal.getCellOffset(event.localPosition); + final lines = (event.scrollDelta.dy / lh).round().clamp(-5, 5); + for (var i = 0; i < lines.abs(); i++) { + final up = lines < 0; + final handled = widget.terminal.mouseInput( + up ? TerminalMouseButton.wheelUp : TerminalMouseButton.wheelDown, + TerminalMouseButtonState.down, + position, + ); + if (!handled && widget.simulateScroll) { + widget.terminal.keyInput(up ? TerminalKey.arrowUp : TerminalKey.arrowDown); + } + } + } + + @override + void initState() { + _focusNode = widget.focusNode ?? FocusNode(); + _controller = widget.controller ?? TerminalController(); + _scrollController = widget.scrollController ?? ScrollController(); + _shortcutManager = ShortcutManager( + shortcuts: widget.shortcuts ?? defaultTerminalShortcuts, + ); + super.initState(); + } + + @override + void didUpdateWidget(TerminalView oldWidget) { + if (oldWidget.focusNode != widget.focusNode) { + if (oldWidget.focusNode == null) { + _focusNode.dispose(); + } + _focusNode = widget.focusNode ?? FocusNode(); + } + if (oldWidget.controller != widget.controller) { + if (oldWidget.controller == null) { + _controller.dispose(); + } + _controller = widget.controller ?? TerminalController(); + } + if (oldWidget.scrollController != widget.scrollController) { + if (oldWidget.scrollController == null) { + _scrollController.dispose(); + } + _scrollController = widget.scrollController ?? ScrollController(); + } + _shortcutManager.shortcuts = widget.shortcuts ?? defaultTerminalShortcuts; + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + if (widget.controller == null) { + _controller.dispose(); + } + if (widget.scrollController == null) { + _scrollController.dispose(); + } + _shortcutManager.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = _TerminalView( + key: _viewportKey, + terminal: widget.terminal, + controller: _controller, + offset: ViewportOffset.zero(), + padding: MediaQuery.of(context).padding, + autoResize: widget.autoResize, + textStyle: widget.textStyle, + textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context), + theme: widget.theme, + focusNode: _focusNode, + cursorType: widget.cursorType, + alwaysShowCursor: widget.alwaysShowCursor, + onEditableRect: _onEditableRect, + composingText: _composingText, + ); + + if (!widget.hardwareKeyboardOnly) { + child = CustomTextEdit( + key: _customTextEditKey, + focusNode: _focusNode, + autofocus: widget.autofocus, + inputType: widget.keyboardType, + keyboardAppearance: widget.keyboardAppearance, + deleteDetection: widget.deleteDetection, + onInsert: _onInsert, + onDelete: () { + _scrollToBottom(); + widget.terminal.keyInput(TerminalKey.backspace); + }, + onComposing: _onComposing, + onAction: (action) { + _scrollToBottom(); + if (action == TextInputAction.done) { + widget.terminal.keyInput(TerminalKey.enter); + } + }, + onKeyEvent: _handleKeyEvent, + readOnly: widget.readOnly, + child: child, + ); + } else if (!widget.readOnly) { + // Only listen for key input from a hardware keyboard. + child = CustomKeyboardListener( + child: child, + focusNode: _focusNode, + autofocus: widget.autofocus, + onInsert: _onInsert, + onComposing: _onComposing, + onKeyEvent: _handleKeyEvent, + ); + } + + child = TerminalActions( + terminal: widget.terminal, + controller: _controller, + child: child, + ); + + child = KeyboardVisibilty( + onKeyboardShow: _onKeyboardShow, + child: child, + ); + + child = TerminalGestureHandler( + terminalView: this, + terminalController: _controller, + onTapUp: _onTapUp, + onTapDown: _onTapDown, + onSecondaryTapDown: + widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null, + onSecondaryTapUp: + widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null, + readOnly: widget.readOnly, + child: child, + ); + + child = MouseRegion( + cursor: widget.mouseCursor, + child: child, + ); + + child = Container( + color: widget.theme.background.withOpacity(widget.backgroundOpacity), + padding: widget.padding, + child: child, + ); + + return Listener( + onPointerSignal: _onPointerSignal, + child: child, + ); + } + + void requestKeyboard() { + _customTextEditKey.currentState?.requestKeyboard(); + } + + void closeKeyboard() { + _customTextEditKey.currentState?.closeKeyboard(); + } + + Rect get cursorRect { + return renderTerminal.cursorOffset & renderTerminal.cellSize; + } + + Rect get globalCursorRect { + return renderTerminal.localToGlobal(renderTerminal.cursorOffset) & + renderTerminal.cellSize; + } + + void _onTapUp(TapUpDetails details) { + final offset = renderTerminal.getCellOffset(details.localPosition); + widget.onTapUp?.call(details, offset); + } + + void _onTapDown(_) { + if (_controller.selection != null) { + _controller.clearSelection(); + } else { + if (!widget.hardwareKeyboardOnly) { + _customTextEditKey.currentState?.requestKeyboard(); + } else { + _focusNode.requestFocus(); + } + } + } + + void _onSecondaryTapDown(TapDownDetails details) { + final offset = renderTerminal.getCellOffset(details.localPosition); + widget.onSecondaryTapDown?.call(details, offset); + } + + void _onSecondaryTapUp(TapUpDetails details) { + final offset = renderTerminal.getCellOffset(details.localPosition); + widget.onSecondaryTapUp?.call(details, offset); + } + + bool get hasInputConnection { + return _customTextEditKey.currentState?.hasInputConnection == true; + } + + void _onInsert(String text) { + final key = charToTerminalKey(text.trim()); + + // On mobile platforms there is no guarantee that virtual keyboard will + // generate hardware key events. So we need first try to send the key + // as a hardware key event. If it fails, then we send it as a text input. + final consumed = key == null ? false : widget.terminal.keyInput(key); + + if (!consumed) { + widget.terminal.textInput(text); + } + + _scrollToBottom(); + } + + void _onComposing(String? text) { + setState(() => _composingText = text); + } + + KeyEventResult _handleKeyEvent(FocusNode focusNode, KeyEvent event) { + final resultOverride = widget.onKeyEvent?.call(focusNode, event); + if (resultOverride != null && resultOverride != KeyEventResult.ignored) { + return resultOverride; + } + + // ignore: invalid_use_of_protected_member + final shortcutResult = _shortcutManager.handleKeypress( + focusNode.context!, + event, + ); + + if (shortcutResult != KeyEventResult.ignored) { + return shortcutResult; + } + + if (event is KeyUpEvent) { + return KeyEventResult.ignored; + } + + final key = keyToTerminalKey(event.logicalKey); + + if (key == null) { + return KeyEventResult.ignored; + } + + final handled = widget.terminal.keyInput( + key, + ctrl: HardwareKeyboard.instance.isControlPressed, + alt: HardwareKeyboard.instance.isAltPressed, + shift: HardwareKeyboard.instance.isShiftPressed, + ); + + if (handled) { + _scrollToBottom(); + } + + return handled ? KeyEventResult.handled : KeyEventResult.ignored; + } + + void _onKeyboardShow() { + if (_focusNode.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + } + } + + void _onEditableRect(Rect rect, Rect caretRect) { + _customTextEditKey.currentState?.setEditableRect(rect, caretRect); + } + + void _scrollToBottom() { + final position = _scrollableKey.currentState?.position; + if (position != null) { + position.jumpTo(position.maxScrollExtent); + } + } +} + +class _TerminalView extends LeafRenderObjectWidget { + const _TerminalView({ + super.key, + required this.terminal, + required this.controller, + required this.offset, + required this.padding, + required this.autoResize, + required this.textStyle, + required this.textScaler, + required this.theme, + required this.focusNode, + required this.cursorType, + required this.alwaysShowCursor, + this.onEditableRect, + this.composingText, + }); + + final Terminal terminal; + + final TerminalController controller; + + final ViewportOffset offset; + + final EdgeInsets padding; + + final bool autoResize; + + final TerminalStyle textStyle; + + final TextScaler textScaler; + + final TerminalTheme theme; + + final FocusNode focusNode; + + final TerminalCursorType cursorType; + + final bool alwaysShowCursor; + + final EditableRectCallback? onEditableRect; + + final String? composingText; + + @override + RenderTerminal createRenderObject(BuildContext context) { + return RenderTerminal( + terminal: terminal, + controller: controller, + offset: offset, + padding: padding, + autoResize: autoResize, + textStyle: textStyle, + textScaler: textScaler, + theme: theme, + focusNode: focusNode, + cursorType: cursorType, + alwaysShowCursor: alwaysShowCursor, + onEditableRect: onEditableRect, + composingText: composingText, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderTerminal renderObject) { + renderObject + ..terminal = terminal + ..controller = controller + ..offset = offset + ..padding = padding + ..autoResize = autoResize + ..textStyle = textStyle + ..textScaler = textScaler + ..theme = theme + ..focusNode = focusNode + ..cursorType = cursorType + ..alwaysShowCursor = alwaysShowCursor + ..onEditableRect = onEditableRect + ..composingText = composingText; + } +} diff --git a/lib/src/terminal/src/ui/char_metrics.dart b/lib/src/terminal/src/ui/char_metrics.dart new file mode 100644 index 00000000..a4393d4f --- /dev/null +++ b/lib/src/terminal/src/ui/char_metrics.dart @@ -0,0 +1,23 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:ui'; + +import 'package:flutter/painting.dart'; +import 'package:clide/src/terminal/src/ui/terminal_text_style.dart'; + +Size calcCharSize(TerminalStyle style, TextScaler textScaler) { + const test = 'mmmmmmmmmm'; + + final textStyle = style.toTextStyle(); + final builder = ParagraphBuilder(textStyle.getParagraphStyle()); + builder.pushStyle(textStyle.getTextStyle(textScaler: textScaler)); + builder.addText(test); + + final paragraph = builder.build(); + paragraph.layout(ParagraphConstraints(width: double.infinity)); + + return Size( + paragraph.maxIntrinsicWidth / test.length, + paragraph.height, + ); +} diff --git a/lib/src/terminal/src/ui/controller.dart b/lib/src/terminal/src/ui/controller.dart new file mode 100644 index 00000000..54d84b42 --- /dev/null +++ b/lib/src/terminal/src/ui/controller.dart @@ -0,0 +1,176 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:clide/src/terminal/src/base/disposable.dart'; +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/line.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; +import 'package:clide/src/terminal/src/core/buffer/range_block.dart'; +import 'package:clide/src/terminal/src/core/buffer/range_line.dart'; +import 'package:clide/src/terminal/src/ui/pointer_input.dart'; +import 'package:clide/src/terminal/src/ui/selection_mode.dart'; + +class TerminalController with ChangeNotifier { + TerminalController({ + SelectionMode selectionMode = SelectionMode.line, + PointerInputs pointerInputs = const PointerInputs({PointerInput.tap}), + bool suspendPointerInput = false, + }) : _selectionMode = selectionMode, + _pointerInputs = pointerInputs, + _suspendPointerInputs = suspendPointerInput; + + CellAnchor? _selectionBase; + CellAnchor? _selectionExtent; + + SelectionMode get selectionMode => _selectionMode; + SelectionMode _selectionMode; + + /// The set of pointer events which will be used as mouse input for the terminal. + PointerInputs get pointerInput => _pointerInputs; + PointerInputs _pointerInputs; + + /// True if sending pointer events to the terminal is suspended. + bool get suspendedPointerInputs => _suspendPointerInputs; + bool _suspendPointerInputs; + + List get highlights => _highlights; + final _highlights = []; + + BufferRange? get selection { + final base = _selectionBase; + final extent = _selectionExtent; + + if (base == null || extent == null) { + return null; + } + + if (!base.attached || !extent.attached) { + return null; + } + + return _createRange(base.offset, extent.offset); + } + + /// Set selection on the terminal from [base] to [extent]. This method takes + /// the ownership of [base] and [extent] and will dispose them when the + /// selection is cleared or changed. + void setSelection(CellAnchor base, CellAnchor extent, {SelectionMode? mode}) { + _selectionBase?.dispose(); + _selectionBase = base; + + _selectionExtent?.dispose(); + _selectionExtent = extent; + + if (mode != null) { + _selectionMode = mode; + } + + notifyListeners(); + } + + BufferRange _createRange(CellOffset begin, CellOffset end) { + switch (selectionMode) { + case SelectionMode.line: + return BufferRangeLine(begin, end); + case SelectionMode.block: + return BufferRangeBlock(begin, end); + } + } + + /// Controls how the terminal behaves when the user selects a range of text. + /// The default is [SelectionMode.line]. Setting this to [SelectionMode.block] + /// enables block selection mode. + void setSelectionMode(SelectionMode newSelectionMode) { + // If the new mode is the same as the old mode, + // nothing has to be changed. + if (_selectionMode == newSelectionMode) { + return; + } + // Set the new mode. + _selectionMode = newSelectionMode; + notifyListeners(); + } + + /// Clears the current selection. + void clearSelection() { + _selectionBase?.dispose(); + _selectionBase = null; + _selectionExtent?.dispose(); + _selectionExtent = null; + notifyListeners(); + } + + // Select which type of pointer events are send to the terminal. + void setPointerInputs(PointerInputs pointerInput) { + _pointerInputs = pointerInput; + notifyListeners(); + } + + // Toggle sending pointer events to the terminal. + void setSuspendPointerInput(bool suspend) { + _suspendPointerInputs = suspend; + notifyListeners(); + } + + // Returns true if this type of PointerInput should be send to the Terminal. + @internal + bool shouldSendPointerInput(PointerInput pointerInput) { + // Always return false if pointer input is suspended. + return _suspendPointerInputs + ? false + : _pointerInputs.inputs.contains(pointerInput); + } + + /// Creates a new highlight on the terminal from [p1] to [p2] with the given + /// [color]. The highlight will be removed when the returned object is + /// disposed. + TerminalHighlight highlight({ + required CellAnchor p1, + required CellAnchor p2, + required Color color, + }) { + final highlight = TerminalHighlight( + this, + p1: p1, + p2: p2, + color: color, + ); + + _highlights.add(highlight); + notifyListeners(); + + highlight.registerCallback(() { + _highlights.remove(highlight); + notifyListeners(); + }); + + return highlight; + } +} + +class TerminalHighlight with Disposable { + final TerminalController owner; + + final CellAnchor p1; + + final CellAnchor p2; + + final Color color; + + TerminalHighlight( + this.owner, { + required this.p1, + required this.p2, + required this.color, + }); + + /// Returns the range of the highlight. May be null if the anchors that + /// define the highlight are not attached to the terminal. + BufferRange? get range { + if (!p1.attached || !p2.attached) { + return null; + } + return BufferRangeLine(p1.offset, p2.offset); + } +} diff --git a/lib/src/terminal/src/ui/cursor_type.dart b/lib/src/terminal/src/ui/cursor_type.dart new file mode 100644 index 00000000..2990cbd2 --- /dev/null +++ b/lib/src/terminal/src/ui/cursor_type.dart @@ -0,0 +1,9 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum TerminalCursorType { + block, + + underline, + + verticalBar, +} diff --git a/lib/src/terminal/src/ui/custom_text_edit.dart b/lib/src/terminal/src/ui/custom_text_edit.dart new file mode 100644 index 00000000..3f92e124 --- /dev/null +++ b/lib/src/terminal/src/ui/custom_text_edit.dart @@ -0,0 +1,283 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CustomTextEdit extends StatefulWidget { + CustomTextEdit({ + super.key, + required this.child, + required this.onInsert, + required this.onDelete, + required this.onComposing, + required this.onAction, + required this.onKeyEvent, + required this.focusNode, + this.autofocus = false, + this.readOnly = false, + // this.initEditingState = TextEditingValue.empty, + this.inputType = TextInputType.text, + this.inputAction = TextInputAction.newline, + this.keyboardAppearance = Brightness.light, + this.deleteDetection = false, + }); + + final Widget child; + + final void Function(String) onInsert; + + final void Function() onDelete; + + final void Function(String?) onComposing; + + final void Function(TextInputAction) onAction; + + final KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent; + + final FocusNode focusNode; + + final bool autofocus; + + final bool readOnly; + + final TextInputType inputType; + + final TextInputAction inputAction; + + final Brightness keyboardAppearance; + + final bool deleteDetection; + + @override + CustomTextEditState createState() => CustomTextEditState(); +} + +class CustomTextEditState extends State with TextInputClient { + TextInputConnection? _connection; + + @override + void initState() { + widget.focusNode.addListener(_onFocusChange); + super.initState(); + } + + @override + void didUpdateWidget(CustomTextEdit oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + } + + if (!_shouldCreateInputConnection) { + _closeInputConnectionIfNeeded(); + } else { + if (oldWidget.readOnly && widget.focusNode.hasFocus) { + _openInputConnection(); + } + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChange); + _closeInputConnectionIfNeeded(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onKeyEvent: _onKeyEvent, + child: widget.child, + ); + } + + bool get hasInputConnection => _connection != null && _connection!.attached; + + void requestKeyboard() { + if (widget.focusNode.hasFocus) { + _openInputConnection(); + } else { + widget.focusNode.requestFocus(); + } + } + + void closeKeyboard() { + if (hasInputConnection) { + _connection?.close(); + } + } + + void setEditingState(TextEditingValue value) { + _currentEditingState = value; + _connection?.setEditingState(value); + } + + void setEditableRect(Rect rect, Rect caretRect) { + if (!hasInputConnection) { + return; + } + + _connection?.setEditableSizeAndTransform( + rect.size, + Matrix4.translationValues(0, 0, 0), + ); + + _connection?.setCaretRect(caretRect); + } + + void _onFocusChange() { + _openOrCloseInputConnectionIfNeeded(); + } + + KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { + if (_currentEditingState.composing.isCollapsed) { + return widget.onKeyEvent(focusNode, event); + } + + return KeyEventResult.skipRemainingHandlers; + } + + void _openOrCloseInputConnectionIfNeeded() { + if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) { + _openInputConnection(); + } else if (!widget.focusNode.hasFocus) { + _closeInputConnectionIfNeeded(); + } + } + + bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly; + + void _openInputConnection() { + if (!_shouldCreateInputConnection) { + return; + } + + if (hasInputConnection) { + _connection!.show(); + } else { + final config = TextInputConfiguration( + inputType: widget.inputType, + inputAction: widget.inputAction, + keyboardAppearance: widget.keyboardAppearance, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + ); + + _connection = TextInput.attach(this, config); + + _connection!.show(); + + // setEditableRect(Rect.zero, Rect.zero); + + _connection!.setEditingState(_initEditingState); + } + } + + void _closeInputConnectionIfNeeded() { + if (_connection != null && _connection!.attached) { + _connection!.close(); + _connection = null; + } + } + + TextEditingValue get _initEditingState => widget.deleteDetection + ? const TextEditingValue( + text: ' ', + selection: TextSelection.collapsed(offset: 2), + ) + : const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + + late var _currentEditingState = _initEditingState.copyWith(); + + @override + TextEditingValue? get currentTextEditingValue { + return _currentEditingState; + } + + @override + AutofillScope? get currentAutofillScope { + return null; + } + + @override + void updateEditingValue(TextEditingValue value) { + _currentEditingState = value; + + // Get input after composing is done + if (!_currentEditingState.composing.isCollapsed) { + final text = _currentEditingState.text; + final composingText = _currentEditingState.composing.textInside(text); + widget.onComposing(composingText); + return; + } + + widget.onComposing(null); + + if (_currentEditingState.text.length < _initEditingState.text.length) { + widget.onDelete(); + } else { + final textDelta = _currentEditingState.text.substring( + _initEditingState.text.length, + ); + + widget.onInsert(textDelta); + } + + // Reset editing state if composing is done + if (_currentEditingState.composing.isCollapsed && + _currentEditingState.text != _initEditingState.text) { + _connection!.setEditingState(_initEditingState); + } + } + + @override + void performAction(TextInputAction action) { + // print('performAction $action'); + widget.onAction(action); + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + // print('updateFloatingCursor $point'); + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + // print('showAutocorrectionPromptRect'); + } + + @override + void connectionClosed() { + // print('connectionClosed'); + } + + @override + void performPrivateCommand(String action, Map data) { + // print('performPrivateCommand $action'); + } + + @override + void insertTextPlaceholder(Size size) { + // print('insertTextPlaceholder'); + } + + @override + void removeTextPlaceholder() { + // print('removeTextPlaceholder'); + } + + @override + void showToolbar() { + // print('showToolbar'); + } +} diff --git a/lib/src/terminal/src/ui/gesture/gesture_detector.dart b/lib/src/terminal/src/ui/gesture/gesture_detector.dart new file mode 100644 index 00000000..16ec2feb --- /dev/null +++ b/lib/src/terminal/src/ui/gesture/gesture_detector.dart @@ -0,0 +1,164 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +class TerminalGestureDetector extends StatefulWidget { + const TerminalGestureDetector({ + super.key, + this.child, + this.onSingleTapUp, + this.onTapUp, + this.onTapDown, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onLongPressStart, + this.onLongPressMoveUpdate, + this.onLongPressUp, + this.onDragStart, + this.onDragUpdate, + this.onDoubleTapDown, + }); + + final Widget? child; + + final GestureTapUpCallback? onTapUp; + + final GestureTapUpCallback? onSingleTapUp; + + final GestureTapDownCallback? onTapDown; + + final GestureTapDownCallback? onSecondaryTapDown; + + final GestureTapUpCallback? onSecondaryTapUp; + + final GestureTapDownCallback? onDoubleTapDown; + + final GestureTapDownCallback? onTertiaryTapDown; + + final GestureTapUpCallback? onTertiaryTapUp; + + final GestureLongPressStartCallback? onLongPressStart; + + final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; + + final GestureLongPressUpCallback? onLongPressUp; + + final GestureDragStartCallback? onDragStart; + + final GestureDragUpdateCallback? onDragUpdate; + + @override + State createState() => + _TerminalGestureDetectorState(); +} + +class _TerminalGestureDetectorState extends State { + Timer? _doubleTapTimer; + + Offset? _lastTapOffset; + + // True if a second tap down of a double tap is detected. Used to discard + // subsequent tap up / tap hold of the same tap. + bool _isDoubleTap = false; + + // The down handler is force-run on success of a single tap and optimistically + // run before a long press success. + void _handleTapDown(TapDownDetails details) { + widget.onTapDown?.call(details); + + if (_doubleTapTimer != null && + _isWithinDoubleTapTolerance(details.globalPosition)) { + // If there was already a previous tap, the second down hold/tap is a + // double tap down. + widget.onDoubleTapDown?.call(details); + + _doubleTapTimer!.cancel(); + _doubleTapTimeout(); + _isDoubleTap = true; + } + } + + void _handleTapUp(TapUpDetails details) { + if (!_isDoubleTap) { + widget.onSingleTapUp?.call(details); + _lastTapOffset = details.globalPosition; + _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); + } + _isDoubleTap = false; + } + + void _doubleTapTimeout() { + _doubleTapTimer = null; + _lastTapOffset = null; + } + + bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { + if (_lastTapOffset == null) { + return false; + } + + final Offset difference = secondTapOffset - _lastTapOffset!; + return difference.distance <= kDoubleTapSlop; + } + + @override + Widget build(BuildContext context) { + final gestures = {}; + + gestures[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onSecondaryTapDown = widget.onSecondaryTapDown + ..onSecondaryTapUp = widget.onSecondaryTapUp + ..onTertiaryTapDown = widget.onTertiaryTapDown + ..onTertiaryTapUp = widget.onTertiaryTapUp; + }, + ); + + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: { + PointerDeviceKind.touch, + // PointerDeviceKind.mouse, // for debugging purposes only + }, + ), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = widget.onLongPressStart + ..onLongPressMoveUpdate = widget.onLongPressMoveUpdate + ..onLongPressUp = widget.onLongPressUp; + }, + ); + + gestures[PanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.mouse}, + ), + (PanGestureRecognizer instance) { + instance + ..dragStartBehavior = DragStartBehavior.down + ..onStart = widget.onDragStart + ..onUpdate = widget.onDragUpdate; + }, + ); + + return RawGestureDetector( + gestures: gestures, + excludeFromSemantics: true, + child: widget.child, + ); + } +} diff --git a/lib/src/terminal/src/ui/gesture/gesture_handler.dart b/lib/src/terminal/src/ui/gesture/gesture_handler.dart new file mode 100644 index 00000000..af011050 --- /dev/null +++ b/lib/src/terminal/src/ui/gesture/gesture_handler.dart @@ -0,0 +1,193 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; +import 'package:clide/src/terminal/src/terminal_view.dart'; +import 'package:clide/src/terminal/src/ui/controller.dart'; +import 'package:clide/src/terminal/src/ui/gesture/gesture_detector.dart'; +import 'package:clide/src/terminal/src/ui/pointer_input.dart'; +import 'package:clide/src/terminal/src/ui/render.dart'; + +class TerminalGestureHandler extends StatefulWidget { + const TerminalGestureHandler({ + super.key, + required this.terminalView, + required this.terminalController, + this.child, + this.onTapUp, + this.onSingleTapUp, + this.onTapDown, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.readOnly = false, + }); + + final TerminalViewState terminalView; + + final TerminalController terminalController; + + final Widget? child; + + final GestureTapUpCallback? onTapUp; + + final GestureTapUpCallback? onSingleTapUp; + + final GestureTapDownCallback? onTapDown; + + final GestureTapDownCallback? onSecondaryTapDown; + + final GestureTapUpCallback? onSecondaryTapUp; + + final GestureTapDownCallback? onTertiaryTapDown; + + final GestureTapUpCallback? onTertiaryTapUp; + + final bool readOnly; + + @override + State createState() => _TerminalGestureHandlerState(); +} + +class _TerminalGestureHandlerState extends State { + TerminalViewState get terminalView => widget.terminalView; + + RenderTerminal get renderTerminal => terminalView.renderTerminal; + + DragStartDetails? _lastDragStartDetails; + + LongPressStartDetails? _lastLongPressStartDetails; + + @override + Widget build(BuildContext context) { + return TerminalGestureDetector( + child: widget.child, + onTapUp: widget.onTapUp, + onSingleTapUp: onSingleTapUp, + onTapDown: onTapDown, + onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTapUp: onSecondaryTapUp, + onTertiaryTapDown: onSecondaryTapDown, + onTertiaryTapUp: onSecondaryTapUp, + onLongPressStart: onLongPressStart, + onLongPressMoveUpdate: onLongPressMoveUpdate, + // onLongPressUp: onLongPressUp, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + onDoubleTapDown: onDoubleTapDown, + ); + } + + bool get _shouldSendTapEvent => + !widget.readOnly && + widget.terminalController.shouldSendPointerInput(PointerInput.tap); + + void _tapDown( + GestureTapDownCallback? callback, + TapDownDetails details, + TerminalMouseButton button, { + bool forceCallback = false, + }) { + // Check if the terminal should and can handle the tap down event. + var handled = false; + if (_shouldSendTapEvent) { + handled = renderTerminal.mouseEvent( + button, + TerminalMouseButtonState.down, + details.localPosition, + ); + } + // If the event was not handled by the terminal, use the supplied callback. + if (!handled || forceCallback) { + callback?.call(details); + } + } + + void _tapUp( + GestureTapUpCallback? callback, + TapUpDetails details, + TerminalMouseButton button, { + bool forceCallback = false, + }) { + // Check if the terminal should and can handle the tap up event. + var handled = false; + if (_shouldSendTapEvent) { + handled = renderTerminal.mouseEvent( + button, + TerminalMouseButtonState.up, + details.localPosition, + ); + } + // If the event was not handled by the terminal, use the supplied callback. + if (!handled || forceCallback) { + callback?.call(details); + } + } + + void onTapDown(TapDownDetails details) { + // onTapDown is special, as it will always call the supplied callback. + // The TerminalView depends on it to bring the terminal into focus. + _tapDown( + widget.onTapDown, + details, + TerminalMouseButton.left, + forceCallback: true, + ); + } + + void onSingleTapUp(TapUpDetails details) { + _tapUp(widget.onSingleTapUp, details, TerminalMouseButton.left); + } + + void onSecondaryTapDown(TapDownDetails details) { + _tapDown(widget.onSecondaryTapDown, details, TerminalMouseButton.right); + } + + void onSecondaryTapUp(TapUpDetails details) { + _tapUp(widget.onSecondaryTapUp, details, TerminalMouseButton.right); + } + + void onTertiaryTapDown(TapDownDetails details) { + _tapDown(widget.onTertiaryTapDown, details, TerminalMouseButton.middle); + } + + void onTertiaryTapUp(TapUpDetails details) { + _tapUp(widget.onTertiaryTapUp, details, TerminalMouseButton.right); + } + + void onDoubleTapDown(TapDownDetails details) { + renderTerminal.selectWord(details.localPosition); + } + + void onLongPressStart(LongPressStartDetails details) { + _lastLongPressStartDetails = details; + renderTerminal.selectWord(details.localPosition); + } + + void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + renderTerminal.selectWord( + _lastLongPressStartDetails!.localPosition, + details.localPosition, + ); + } + + // void onLongPressUp() {} + + void onDragStart(DragStartDetails details) { + _lastDragStartDetails = details; + + details.kind == PointerDeviceKind.mouse + ? renderTerminal.selectCharacters(details.localPosition) + : renderTerminal.selectWord(details.localPosition); + } + + void onDragUpdate(DragUpdateDetails details) { + renderTerminal.selectCharacters( + _lastDragStartDetails!.localPosition, + details.localPosition, + ); + } +} diff --git a/lib/src/terminal/src/ui/infinite_scroll_view.dart b/lib/src/terminal/src/ui/infinite_scroll_view.dart new file mode 100644 index 00000000..82612859 --- /dev/null +++ b/lib/src/terminal/src/ui/infinite_scroll_view.dart @@ -0,0 +1,117 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// The function called when the user scrolls the [InfiniteScrollView]. [offset] +/// is the current offset of the scroll view, ranging from [double.negativeInfinity] +/// to [double.infinity]. +typedef ScrollCallback = void Function(double offset); + +/// A [Scrollable] that can be scrolled infinitely in both directions. When +/// scroll happens, the [onScroll] callback is called with the new offset. +class InfiniteScrollView extends StatelessWidget { + const InfiniteScrollView({ + super.key, + required this.onScroll, + required this.child, + }); + + final ScrollCallback onScroll; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Scrollable( + viewportBuilder: (context, position) { + return _InfiniteScrollView( + position: position, + onScroll: onScroll, + child: child, + ); + }, + ); + } +} + +class _InfiniteScrollView extends SingleChildRenderObjectWidget { + const _InfiniteScrollView({ + // super.key, + super.child, + required this.position, + required this.onScroll, + }); + + final ViewportOffset position; + + final ScrollCallback onScroll; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInfiniteScrollView( + position: position, + onScroll: onScroll, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderInfiniteScrollView renderObject, + ) { + renderObject + ..position = position + ..onScroll = onScroll; + } +} + +class _RenderInfiniteScrollView extends RenderShiftedBox { + _RenderInfiniteScrollView({ + RenderBox? child, + required ViewportOffset position, + required ScrollCallback onScroll, + }) : _position = position, + _scrollCallback = onScroll, + super(child); + + ViewportOffset _position; + set position(ViewportOffset value) { + if (_position == value) return; + if (attached) _position.removeListener(markNeedsLayout); + _position = value; + if (attached) _position.addListener(markNeedsLayout); + markNeedsLayout(); + } + + ScrollCallback _scrollCallback; + set onScroll(ScrollCallback value) { + if (_scrollCallback == value) return; + _scrollCallback = value; + markNeedsLayout(); + } + + void _onScroll() { + _scrollCallback(_position.pixels); + } + + @override + void attach(covariant PipelineOwner owner) { + super.attach(owner); + _position.addListener(_onScroll); + } + + @override + void detach() { + super.detach(); + _position.removeListener(_onScroll); + } + + @override + void performLayout() { + child?.layout(constraints, parentUsesSize: true); + size = child?.size ?? Size.zero; + _position.applyViewportDimension(size.height); + _position.applyContentDimensions(double.negativeInfinity, double.infinity); + } +} diff --git a/lib/src/terminal/src/ui/input_map.dart b/lib/src/terminal/src/ui/input_map.dart new file mode 100644 index 00000000..cc1e8a52 --- /dev/null +++ b/lib/src/terminal/src/ui/input_map.dart @@ -0,0 +1,290 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/services.dart'; +import 'package:clide/src/terminal/src/core/input/keys.dart'; + +final _keyToTerminalKey = { + LogicalKeyboardKey.hyper: TerminalKey.hyper, + LogicalKeyboardKey.superKey: TerminalKey.superKey, + LogicalKeyboardKey.fnLock: TerminalKey.fnLock, + LogicalKeyboardKey.suspend: TerminalKey.suspend, + LogicalKeyboardKey.resume: TerminalKey.resume, + LogicalKeyboardKey.sleep: TerminalKey.sleep, + LogicalKeyboardKey.wakeUp: TerminalKey.wakeUp, + LogicalKeyboardKey.keyA: TerminalKey.keyA, + LogicalKeyboardKey.keyB: TerminalKey.keyB, + LogicalKeyboardKey.keyC: TerminalKey.keyC, + LogicalKeyboardKey.keyD: TerminalKey.keyD, + LogicalKeyboardKey.keyE: TerminalKey.keyE, + LogicalKeyboardKey.keyF: TerminalKey.keyF, + LogicalKeyboardKey.keyG: TerminalKey.keyG, + LogicalKeyboardKey.keyH: TerminalKey.keyH, + LogicalKeyboardKey.keyI: TerminalKey.keyI, + LogicalKeyboardKey.keyJ: TerminalKey.keyJ, + LogicalKeyboardKey.keyK: TerminalKey.keyK, + LogicalKeyboardKey.keyL: TerminalKey.keyL, + LogicalKeyboardKey.keyM: TerminalKey.keyM, + LogicalKeyboardKey.keyN: TerminalKey.keyN, + LogicalKeyboardKey.keyO: TerminalKey.keyO, + LogicalKeyboardKey.keyP: TerminalKey.keyP, + LogicalKeyboardKey.keyQ: TerminalKey.keyQ, + LogicalKeyboardKey.keyR: TerminalKey.keyR, + LogicalKeyboardKey.keyS: TerminalKey.keyS, + LogicalKeyboardKey.keyT: TerminalKey.keyT, + LogicalKeyboardKey.keyU: TerminalKey.keyU, + LogicalKeyboardKey.keyV: TerminalKey.keyV, + LogicalKeyboardKey.keyW: TerminalKey.keyW, + LogicalKeyboardKey.keyX: TerminalKey.keyX, + LogicalKeyboardKey.keyY: TerminalKey.keyY, + LogicalKeyboardKey.keyZ: TerminalKey.keyZ, + LogicalKeyboardKey.digit1: TerminalKey.digit1, + LogicalKeyboardKey.digit2: TerminalKey.digit2, + LogicalKeyboardKey.digit3: TerminalKey.digit3, + LogicalKeyboardKey.digit4: TerminalKey.digit4, + LogicalKeyboardKey.digit5: TerminalKey.digit5, + LogicalKeyboardKey.digit6: TerminalKey.digit6, + LogicalKeyboardKey.digit7: TerminalKey.digit7, + LogicalKeyboardKey.digit8: TerminalKey.digit8, + LogicalKeyboardKey.digit9: TerminalKey.digit9, + LogicalKeyboardKey.digit0: TerminalKey.digit0, + LogicalKeyboardKey.enter: TerminalKey.enter, + LogicalKeyboardKey.escape: TerminalKey.escape, + LogicalKeyboardKey.backspace: TerminalKey.backspace, + LogicalKeyboardKey.tab: TerminalKey.tab, + LogicalKeyboardKey.space: TerminalKey.space, + LogicalKeyboardKey.minus: TerminalKey.minus, + LogicalKeyboardKey.equal: TerminalKey.equal, + LogicalKeyboardKey.bracketLeft: TerminalKey.bracketLeft, + LogicalKeyboardKey.bracketRight: TerminalKey.bracketRight, + LogicalKeyboardKey.backslash: TerminalKey.backslash, + LogicalKeyboardKey.semicolon: TerminalKey.semicolon, + LogicalKeyboardKey.quote: TerminalKey.quote, + LogicalKeyboardKey.backquote: TerminalKey.backquote, + LogicalKeyboardKey.comma: TerminalKey.comma, + LogicalKeyboardKey.period: TerminalKey.period, + LogicalKeyboardKey.slash: TerminalKey.slash, + LogicalKeyboardKey.capsLock: TerminalKey.capsLock, + LogicalKeyboardKey.f1: TerminalKey.f1, + LogicalKeyboardKey.f2: TerminalKey.f2, + LogicalKeyboardKey.f3: TerminalKey.f3, + LogicalKeyboardKey.f4: TerminalKey.f4, + LogicalKeyboardKey.f5: TerminalKey.f5, + LogicalKeyboardKey.f6: TerminalKey.f6, + LogicalKeyboardKey.f7: TerminalKey.f7, + LogicalKeyboardKey.f8: TerminalKey.f8, + LogicalKeyboardKey.f9: TerminalKey.f9, + LogicalKeyboardKey.f10: TerminalKey.f10, + LogicalKeyboardKey.f11: TerminalKey.f11, + LogicalKeyboardKey.f12: TerminalKey.f12, + LogicalKeyboardKey.printScreen: TerminalKey.printScreen, + LogicalKeyboardKey.scrollLock: TerminalKey.scrollLock, + LogicalKeyboardKey.pause: TerminalKey.pause, + LogicalKeyboardKey.insert: TerminalKey.insert, + LogicalKeyboardKey.home: TerminalKey.home, + LogicalKeyboardKey.pageUp: TerminalKey.pageUp, + LogicalKeyboardKey.delete: TerminalKey.delete, + LogicalKeyboardKey.end: TerminalKey.end, + LogicalKeyboardKey.pageDown: TerminalKey.pageDown, + LogicalKeyboardKey.arrowRight: TerminalKey.arrowRight, + LogicalKeyboardKey.arrowLeft: TerminalKey.arrowLeft, + LogicalKeyboardKey.arrowDown: TerminalKey.arrowDown, + LogicalKeyboardKey.arrowUp: TerminalKey.arrowUp, + LogicalKeyboardKey.numLock: TerminalKey.numLock, + LogicalKeyboardKey.numpadDivide: TerminalKey.numpadDivide, + LogicalKeyboardKey.numpadMultiply: TerminalKey.numpadMultiply, + LogicalKeyboardKey.numpadSubtract: TerminalKey.numpadSubtract, + LogicalKeyboardKey.numpadAdd: TerminalKey.numpadAdd, + LogicalKeyboardKey.numpadEnter: TerminalKey.numpadEnter, + LogicalKeyboardKey.numpad1: TerminalKey.numpad1, + LogicalKeyboardKey.numpad2: TerminalKey.numpad2, + LogicalKeyboardKey.numpad3: TerminalKey.numpad3, + LogicalKeyboardKey.numpad4: TerminalKey.numpad4, + LogicalKeyboardKey.numpad5: TerminalKey.numpad5, + LogicalKeyboardKey.numpad6: TerminalKey.numpad6, + LogicalKeyboardKey.numpad7: TerminalKey.numpad7, + LogicalKeyboardKey.numpad8: TerminalKey.numpad8, + LogicalKeyboardKey.numpad9: TerminalKey.numpad9, + LogicalKeyboardKey.numpad0: TerminalKey.numpad0, + LogicalKeyboardKey.numpadDecimal: TerminalKey.numpadDecimal, + LogicalKeyboardKey.intlBackslash: TerminalKey.intlBackslash, + LogicalKeyboardKey.contextMenu: TerminalKey.contextMenu, + LogicalKeyboardKey.power: TerminalKey.power, + LogicalKeyboardKey.numpadEqual: TerminalKey.numpadEqual, + LogicalKeyboardKey.f13: TerminalKey.f13, + LogicalKeyboardKey.f14: TerminalKey.f14, + LogicalKeyboardKey.f15: TerminalKey.f15, + LogicalKeyboardKey.f16: TerminalKey.f16, + LogicalKeyboardKey.f17: TerminalKey.f17, + LogicalKeyboardKey.f18: TerminalKey.f18, + LogicalKeyboardKey.f19: TerminalKey.f19, + LogicalKeyboardKey.f20: TerminalKey.f20, + LogicalKeyboardKey.f21: TerminalKey.f21, + LogicalKeyboardKey.f22: TerminalKey.f22, + LogicalKeyboardKey.f23: TerminalKey.f23, + LogicalKeyboardKey.f24: TerminalKey.f24, + LogicalKeyboardKey.open: TerminalKey.open, + LogicalKeyboardKey.help: TerminalKey.help, + LogicalKeyboardKey.select: TerminalKey.select, + LogicalKeyboardKey.again: TerminalKey.again, + LogicalKeyboardKey.undo: TerminalKey.undo, + LogicalKeyboardKey.cut: TerminalKey.cut, + LogicalKeyboardKey.copy: TerminalKey.copy, + LogicalKeyboardKey.paste: TerminalKey.paste, + LogicalKeyboardKey.find: TerminalKey.find, + LogicalKeyboardKey.audioVolumeMute: TerminalKey.audioVolumeMute, + LogicalKeyboardKey.audioVolumeUp: TerminalKey.audioVolumeUp, + LogicalKeyboardKey.audioVolumeDown: TerminalKey.audioVolumeDown, + LogicalKeyboardKey.numpadComma: TerminalKey.numpadComma, + LogicalKeyboardKey.intlRo: TerminalKey.intlRo, + LogicalKeyboardKey.kanaMode: TerminalKey.kanaMode, + LogicalKeyboardKey.intlYen: TerminalKey.intlYen, + LogicalKeyboardKey.convert: TerminalKey.convert, + LogicalKeyboardKey.nonConvert: TerminalKey.nonConvert, + LogicalKeyboardKey.lang1: TerminalKey.lang1, + LogicalKeyboardKey.lang2: TerminalKey.lang2, + LogicalKeyboardKey.lang3: TerminalKey.lang3, + LogicalKeyboardKey.lang4: TerminalKey.lang4, + LogicalKeyboardKey.lang5: TerminalKey.lang5, + LogicalKeyboardKey.abort: TerminalKey.abort, + LogicalKeyboardKey.props: TerminalKey.props, + LogicalKeyboardKey.numpadParenLeft: TerminalKey.numpadParenLeft, + LogicalKeyboardKey.numpadParenRight: TerminalKey.numpadParenRight, + LogicalKeyboardKey.controlLeft: TerminalKey.controlLeft, + LogicalKeyboardKey.shiftLeft: TerminalKey.shiftLeft, + LogicalKeyboardKey.altLeft: TerminalKey.altLeft, + LogicalKeyboardKey.metaLeft: TerminalKey.metaLeft, + LogicalKeyboardKey.controlRight: TerminalKey.controlRight, + LogicalKeyboardKey.shiftRight: TerminalKey.shiftRight, + LogicalKeyboardKey.altRight: TerminalKey.altRight, + LogicalKeyboardKey.metaRight: TerminalKey.metaRight, + LogicalKeyboardKey.info: TerminalKey.info, + LogicalKeyboardKey.closedCaptionToggle: TerminalKey.closedCaptionToggle, + LogicalKeyboardKey.brightnessUp: TerminalKey.brightnessUp, + LogicalKeyboardKey.brightnessDown: TerminalKey.brightnessDown, + LogicalKeyboardKey.mediaLast: TerminalKey.mediaLast, + LogicalKeyboardKey.launchPhone: TerminalKey.launchPhone, + LogicalKeyboardKey.exit: TerminalKey.exit, + LogicalKeyboardKey.channelUp: TerminalKey.channelUp, + LogicalKeyboardKey.channelDown: TerminalKey.channelDown, + LogicalKeyboardKey.mediaPlay: TerminalKey.mediaPlay, + LogicalKeyboardKey.mediaPause: TerminalKey.mediaPause, + LogicalKeyboardKey.mediaRecord: TerminalKey.mediaRecord, + LogicalKeyboardKey.mediaFastForward: TerminalKey.mediaFastForward, + LogicalKeyboardKey.mediaRewind: TerminalKey.mediaRewind, + LogicalKeyboardKey.mediaTrackNext: TerminalKey.mediaTrackNext, + LogicalKeyboardKey.mediaTrackPrevious: TerminalKey.mediaTrackPrevious, + LogicalKeyboardKey.mediaStop: TerminalKey.mediaStop, + LogicalKeyboardKey.eject: TerminalKey.eject, + LogicalKeyboardKey.mediaPlayPause: TerminalKey.mediaPlayPause, + LogicalKeyboardKey.speechInputToggle: TerminalKey.speechInputToggle, + LogicalKeyboardKey.launchWordProcessor: TerminalKey.launchWordProcessor, + LogicalKeyboardKey.launchSpreadsheet: TerminalKey.launchSpreadsheet, + LogicalKeyboardKey.launchMail: TerminalKey.launchMail, + LogicalKeyboardKey.launchContacts: TerminalKey.launchContacts, + LogicalKeyboardKey.launchCalendar: TerminalKey.launchCalendar, + LogicalKeyboardKey.logOff: TerminalKey.logOff, + LogicalKeyboardKey.launchControlPanel: TerminalKey.launchControlPanel, + LogicalKeyboardKey.spellCheck: TerminalKey.spellCheck, + LogicalKeyboardKey.launchScreenSaver: TerminalKey.launchScreenSaver, + LogicalKeyboardKey.launchAssistant: TerminalKey.launchAssistant, + LogicalKeyboardKey.newKey: TerminalKey.newKey, + LogicalKeyboardKey.close: TerminalKey.close, + LogicalKeyboardKey.save: TerminalKey.save, + LogicalKeyboardKey.print: TerminalKey.print, + LogicalKeyboardKey.browserSearch: TerminalKey.browserSearch, + LogicalKeyboardKey.browserHome: TerminalKey.browserHome, + LogicalKeyboardKey.browserBack: TerminalKey.browserBack, + LogicalKeyboardKey.browserForward: TerminalKey.browserForward, + LogicalKeyboardKey.browserStop: TerminalKey.browserStop, + LogicalKeyboardKey.browserRefresh: TerminalKey.browserRefresh, + LogicalKeyboardKey.browserFavorites: TerminalKey.browserFavorites, + LogicalKeyboardKey.zoomIn: TerminalKey.zoomIn, + LogicalKeyboardKey.zoomOut: TerminalKey.zoomOut, + LogicalKeyboardKey.zoomToggle: TerminalKey.zoomToggle, + LogicalKeyboardKey.redo: TerminalKey.redo, + LogicalKeyboardKey.mailReply: TerminalKey.mailReply, + LogicalKeyboardKey.mailForward: TerminalKey.mailForward, + LogicalKeyboardKey.mailSend: TerminalKey.mailSend, + LogicalKeyboardKey.gameButton1: TerminalKey.gameButton1, + LogicalKeyboardKey.gameButton2: TerminalKey.gameButton2, + LogicalKeyboardKey.gameButton3: TerminalKey.gameButton3, + LogicalKeyboardKey.gameButton4: TerminalKey.gameButton4, + LogicalKeyboardKey.gameButton5: TerminalKey.gameButton5, + LogicalKeyboardKey.gameButton6: TerminalKey.gameButton6, + LogicalKeyboardKey.gameButton7: TerminalKey.gameButton7, + LogicalKeyboardKey.gameButton8: TerminalKey.gameButton8, + LogicalKeyboardKey.gameButton9: TerminalKey.gameButton9, + LogicalKeyboardKey.gameButton10: TerminalKey.gameButton10, + LogicalKeyboardKey.gameButton11: TerminalKey.gameButton11, + LogicalKeyboardKey.gameButton12: TerminalKey.gameButton12, + LogicalKeyboardKey.gameButton13: TerminalKey.gameButton13, + LogicalKeyboardKey.gameButton14: TerminalKey.gameButton14, + LogicalKeyboardKey.gameButton15: TerminalKey.gameButton15, + LogicalKeyboardKey.gameButton16: TerminalKey.gameButton16, + LogicalKeyboardKey.gameButtonA: TerminalKey.gameButtonA, + LogicalKeyboardKey.gameButtonB: TerminalKey.gameButtonB, + LogicalKeyboardKey.gameButtonC: TerminalKey.gameButtonC, + LogicalKeyboardKey.gameButtonLeft1: TerminalKey.gameButtonLeft1, + LogicalKeyboardKey.gameButtonLeft2: TerminalKey.gameButtonLeft2, + LogicalKeyboardKey.gameButtonMode: TerminalKey.gameButtonMode, + LogicalKeyboardKey.gameButtonRight1: TerminalKey.gameButtonRight1, + LogicalKeyboardKey.gameButtonRight2: TerminalKey.gameButtonRight2, + LogicalKeyboardKey.gameButtonSelect: TerminalKey.gameButtonSelect, + LogicalKeyboardKey.gameButtonStart: TerminalKey.gameButtonStart, + LogicalKeyboardKey.gameButtonThumbLeft: TerminalKey.gameButtonThumbLeft, + LogicalKeyboardKey.gameButtonThumbRight: TerminalKey.gameButtonThumbRight, + LogicalKeyboardKey.gameButtonX: TerminalKey.gameButtonX, + LogicalKeyboardKey.gameButtonY: TerminalKey.gameButtonY, + LogicalKeyboardKey.gameButtonZ: TerminalKey.gameButtonZ, + LogicalKeyboardKey.fn: TerminalKey.fn, + LogicalKeyboardKey.shift: TerminalKey.shift, + LogicalKeyboardKey.meta: TerminalKey.meta, + LogicalKeyboardKey.alt: TerminalKey.alt, + LogicalKeyboardKey.control: TerminalKey.control, +}; + +final _keyById = () { + final map = {}; + for (final entry in _keyToTerminalKey.entries) { + map[entry.key.keyId] = entry.value; + } + return map; +}(); + +final _keyByChar = () { + final map = {}; + for (final entry in _keyToTerminalKey.entries) { + final label = entry.key.keyLabel; + + if (label.isEmpty || label.length > 1) { + continue; + } + + map[label] = entry.value; + map[label.toUpperCase()] = entry.value; + map[label.toLowerCase()] = entry.value; + } + return map; +}(); + +/// Converts a [LogicalKeyboardKey] to a [TerminalKey]. Returns `null` if the +/// key does not have a corresponding [TerminalKey]. +/// +/// For example, `LogicalKeyboardKey.keyA` will be converted to +/// `TerminalKey.keyA`. +TerminalKey? keyToTerminalKey(LogicalKeyboardKey key) { + return _keyById[key.keyId]; +} + +/// Converts a character to a [TerminalKey]. Returns `null` if the character +/// does not have a corresponding [TerminalKey]. +/// +/// For example, `charToTerminalKey('a')` or `charToTerminalKey('A')` will both +/// return [TerminalKey.a]. +TerminalKey? charToTerminalKey(String char) { + if (char.length != 1) { + return null; + } + + return _keyByChar[char]; +} diff --git a/lib/src/terminal/src/ui/keyboard_listener.dart b/lib/src/terminal/src/ui/keyboard_listener.dart new file mode 100644 index 00000000..8513ff33 --- /dev/null +++ b/lib/src/terminal/src/ui/keyboard_listener.dart @@ -0,0 +1,51 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; + +class CustomKeyboardListener extends StatelessWidget { + final Widget child; + + final FocusNode focusNode; + + final bool autofocus; + + final void Function(String) onInsert; + + final void Function(String?) onComposing; + + final KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent; + + const CustomKeyboardListener({ + super.key, + required this.child, + required this.focusNode, + this.autofocus = false, + required this.onInsert, + required this.onComposing, + required this.onKeyEvent, + }); + + KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent keyEvent) { + // First try to handle the key event directly. + final handled = onKeyEvent(focusNode, keyEvent); + if (handled == KeyEventResult.ignored) { + // If it was not handled, but the key corresponds to a character, + // insert the character. + if (keyEvent.character != null && keyEvent.character != "") { + onInsert(keyEvent.character!); + return KeyEventResult.handled; + } + } + return handled; + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: focusNode, + autofocus: autofocus, + onKeyEvent: _onKeyEvent, + child: child, + ); + } +} diff --git a/lib/src/terminal/src/ui/keyboard_visibility.dart b/lib/src/terminal/src/ui/keyboard_visibility.dart new file mode 100644 index 00000000..8988ee15 --- /dev/null +++ b/lib/src/terminal/src/ui/keyboard_visibility.dart @@ -0,0 +1,60 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; + +class KeyboardVisibilty extends StatefulWidget { + const KeyboardVisibilty({ + super.key, + required this.child, + this.onKeyboardShow, + this.onKeyboardHide, + }); + + final Widget child; + + final VoidCallback? onKeyboardShow; + + final VoidCallback? onKeyboardHide; + + @override + KeyboardVisibiltyState createState() => KeyboardVisibiltyState(); +} + +class KeyboardVisibiltyState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + final bottomInset = View.of(context).viewInsets.bottom; + + if (bottomInset != _lastBottomInset) { + if (bottomInset > 0) { + widget.onKeyboardShow?.call(); + } else { + widget.onKeyboardHide?.call(); + } + } + + _lastBottomInset = bottomInset; + + super.didChangeMetrics(); + } + + var _lastBottomInset = 0.0; + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/src/terminal/src/ui/painter.dart b/lib/src/terminal/src/ui/painter.dart new file mode 100644 index 00000000..522e8f92 --- /dev/null +++ b/lib/src/terminal/src/ui/painter.dart @@ -0,0 +1,281 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:ui'; +import 'package:flutter/painting.dart'; + +import 'package:clide/src/terminal/src/ui/palette_builder.dart'; +import 'package:clide/src/terminal/src/ui/paragraph_cache.dart'; +import 'package:clide/src/terminal/terminal.dart'; + +/// Encapsulates the logic for painting various terminal elements. +class TerminalPainter { + TerminalPainter({ + required TerminalTheme theme, + required TerminalStyle textStyle, + required TextScaler textScaler, + }) : _textStyle = textStyle, + _theme = theme, + _textScaler = textScaler; + + /// A lookup table from terminal colors to Flutter colors. + late var _colorPalette = PaletteBuilder(_theme).build(); + + /// Size of each character in the terminal. + late var _cellSize = _measureCharSize(); + + /// The cached for cells in the terminal. Should be cleared when the same + /// cell no longer produces the same visual output. For example, when + /// [_textStyle] is changed, or when the system font changes. + final _paragraphCache = ParagraphCache(10240); + + TerminalStyle get textStyle => _textStyle; + TerminalStyle _textStyle; + set textStyle(TerminalStyle value) { + if (value == _textStyle) return; + _textStyle = value; + _cellSize = _measureCharSize(); + _paragraphCache.clear(); + } + + TextScaler get textScaler => _textScaler; + TextScaler _textScaler = TextScaler.linear(1.0); + set textScaler(TextScaler value) { + if (value == _textScaler) return; + _textScaler = value; + _cellSize = _measureCharSize(); + _paragraphCache.clear(); + } + + TerminalTheme get theme => _theme; + TerminalTheme _theme; + set theme(TerminalTheme value) { + if (value == _theme) return; + _theme = value; + _colorPalette = PaletteBuilder(value).build(); + _paragraphCache.clear(); + } + + Size _measureCharSize() { + const test = 'mmmmmmmmmm'; + + final textStyle = _textStyle.toTextStyle(); + final builder = ParagraphBuilder(textStyle.getParagraphStyle()); + builder.pushStyle( + textStyle.getTextStyle(textScaler: _textScaler), + ); + builder.addText(test); + + final paragraph = builder.build(); + paragraph.layout(ParagraphConstraints(width: double.infinity)); + + final result = Size( + paragraph.maxIntrinsicWidth / test.length, + paragraph.height, + ); + + paragraph.dispose(); + return result; + } + + /// The size of each character in the terminal. + Size get cellSize => _cellSize; + + /// When the set of font available to the system changes, call this method to + /// clear cached state related to font rendering. + void clearFontCache() { + _cellSize = _measureCharSize(); + _paragraphCache.clear(); + } + + /// Paints the cursor based on the current cursor type. + void paintCursor( + Canvas canvas, + Offset offset, { + required TerminalCursorType cursorType, + bool hasFocus = true, + }) { + final paint = Paint() + ..color = _theme.cursor + ..strokeWidth = 1; + + if (!hasFocus) { + paint.style = PaintingStyle.stroke; + canvas.drawRect(offset & _cellSize, paint); + return; + } + + switch (cursorType) { + case TerminalCursorType.block: + paint.style = PaintingStyle.fill; + canvas.drawRect(offset & _cellSize, paint); + return; + case TerminalCursorType.underline: + return canvas.drawLine( + Offset(offset.dx, _cellSize.height - 1), + Offset(offset.dx + _cellSize.width, _cellSize.height - 1), + paint, + ); + case TerminalCursorType.verticalBar: + return canvas.drawLine( + Offset(offset.dx, 0), + Offset(offset.dx, _cellSize.height), + paint, + ); + } + } + + @pragma('vm:prefer-inline') + void paintHighlight(Canvas canvas, Offset offset, int length, Color color) { + final endOffset = + offset.translate(length * _cellSize.width, _cellSize.height); + + final paint = Paint() + ..color = color + ..strokeWidth = 1; + + canvas.drawRect( + Rect.fromPoints(offset, endOffset), + paint, + ); + } + + /// Paints [line] to [canvas] at [offset]. The x offset of [offset] is usually + /// 0, and the y offset is the top of the line. + void paintLine( + Canvas canvas, + Offset offset, + BufferLine line, + ) { + final cellData = CellData.empty(); + final cellWidth = _cellSize.width; + + for (var i = 0; i < line.length; i++) { + line.getCellData(i, cellData); + + final charWidth = cellData.content >> CellContent.widthShift; + final cellOffset = offset.translate(i * cellWidth, 0); + + paintCell(canvas, cellOffset, cellData); + + if (charWidth == 2) { + i++; + } + } + } + + @pragma('vm:prefer-inline') + void paintCell(Canvas canvas, Offset offset, CellData cellData) { + paintCellBackground(canvas, offset, cellData); + paintCellForeground(canvas, offset, cellData); + } + + /// Paints the character in the cell represented by [cellData] to [canvas] at + /// [offset]. + @pragma('vm:prefer-inline') + void paintCellForeground(Canvas canvas, Offset offset, CellData cellData) { + final charCode = cellData.content & CellContent.codepointMask; + if (charCode == 0) return; + + final cacheKey = cellData.getHash() ^ _textScaler.hashCode; + var paragraph = _paragraphCache.getLayoutFromCache(cacheKey); + + if (paragraph == null) { + final cellFlags = cellData.flags; + + var color = cellFlags & CellFlags.inverse == 0 + ? resolveForegroundColor(cellData.foreground) + : resolveBackgroundColor(cellData.background); + + if (cellData.flags & CellFlags.faint != 0) { + color = color.withOpacity(0.5); + } + + final style = _textStyle.toTextStyle( + color: color, + bold: cellFlags & CellFlags.bold != 0, + italic: cellFlags & CellFlags.italic != 0, + underline: cellFlags & CellFlags.underline != 0, + ); + + // Flutter does not draw an underline below a space which is not between + // other regular characters. As only single characters are drawn, this + // will never produce an underline below a space in the terminal. As a + // workaround the regular space CodePoint 0x20 is replaced with + // the CodePoint 0xA0. This is a non breaking space and a underline can be + // drawn below it. + var char = String.fromCharCode(charCode); + if (cellFlags & CellFlags.underline != 0 && charCode == 0x20) { + char = String.fromCharCode(0xA0); + } + + paragraph = _paragraphCache.performAndCacheLayout( + char, + style, + _textScaler, + cacheKey, + ); + } + + canvas.drawParagraph(paragraph, offset); + } + + /// Paints the background of a cell represented by [cellData] to [canvas] at + /// [offset]. + @pragma('vm:prefer-inline') + void paintCellBackground(Canvas canvas, Offset offset, CellData cellData) { + late Color color; + final colorType = cellData.background & CellColor.typeMask; + + if (cellData.flags & CellFlags.inverse != 0) { + color = resolveForegroundColor(cellData.foreground); + } else if (colorType == CellColor.normal) { + return; + } else { + color = resolveBackgroundColor(cellData.background); + } + + final paint = Paint()..color = color; + final doubleWidth = cellData.content >> CellContent.widthShift == 2; + final widthScale = doubleWidth ? 2 : 1; + final size = Size(_cellSize.width * widthScale + 1, _cellSize.height); + canvas.drawRect(offset & size, paint); + } + + /// Get the effective foreground color for a cell from information encoded in + /// [cellColor]. + @pragma('vm:prefer-inline') + Color resolveForegroundColor(int cellColor) { + final colorType = cellColor & CellColor.typeMask; + final colorValue = cellColor & CellColor.valueMask; + + switch (colorType) { + case CellColor.normal: + return _theme.foreground; + case CellColor.named: + case CellColor.palette: + return _colorPalette[colorValue]; + case CellColor.rgb: + default: + return Color(colorValue | 0xFF000000); + } + } + + /// Get the effective background color for a cell from information encoded in + /// [cellColor]. + @pragma('vm:prefer-inline') + Color resolveBackgroundColor(int cellColor) { + final colorType = cellColor & CellColor.typeMask; + final colorValue = cellColor & CellColor.valueMask; + + switch (colorType) { + case CellColor.normal: + return _theme.background; + case CellColor.named: + case CellColor.palette: + return _colorPalette[colorValue]; + case CellColor.rgb: + default: + return Color(colorValue | 0xFF000000); + } + } +} diff --git a/lib/src/terminal/src/ui/palette_builder.dart b/lib/src/terminal/src/ui/palette_builder.dart new file mode 100644 index 00000000..4f233f6e --- /dev/null +++ b/lib/src/terminal/src/ui/palette_builder.dart @@ -0,0 +1,120 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/src/ui/terminal_theme.dart'; +import 'package:clide/src/terminal/src/utils/lookup_table.dart'; + +class PaletteBuilder { + final TerminalTheme theme; + + PaletteBuilder(this.theme); + + List build() { + return List.generate( + 256, + paletteColor, + growable: false, + ); + } + + /// https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + Color paletteColor(int colNum) { + switch (colNum) { + case 0: + return theme.black; + case 1: + return theme.red; + case 2: + return theme.green; + case 3: + return theme.yellow; + case 4: + return theme.blue; + case 5: + return theme.magenta; + case 6: + return theme.cyan; + case 7: + return theme.white; + case 8: + return theme.brightBlack; + case 9: + return theme.brightRed; + case 10: + return theme.brightGreen; + case 11: + return theme.brightYellow; + case 12: + return theme.brightBlue; + case 13: + return theme.brightMagenta; + case 14: + return theme.brightCyan; + case 15: + return theme.white; + } + + if (colNum < 232) { + var r = 0; + var g = 0; + var b = 0; + + final index = colNum - 16; + + for (var i = 0; i < index; i++) { + if (b == 0) { + b = 95; + } else if (b < 255) { + b += 40; + } else { + b = 0; + if (g == 0) { + g = 95; + } else if (g < 255) { + g += 40; + } else { + g = 0; + if (r == 0) { + r = 95; + } else if (r < 255) { + r += 40; + } else { + break; + } + } + } + } + + return Color.fromARGB(0xFF, r, g, b); + } + + return Color(_grayscaleColors[colNum.clamp(232, 255)]!); + } +} + +final _grayscaleColors = FastLookupTable({ + 232: 0xff080808, + 233: 0xff121212, + 234: 0xff1c1c1c, + 235: 0xff262626, + 236: 0xff303030, + 237: 0xff3a3a3a, + 238: 0xff444444, + 239: 0xff4e4e4e, + 240: 0xff585858, + 241: 0xff626262, + 242: 0xff6c6c6c, + 243: 0xff767676, + 244: 0xff808080, + 245: 0xff8a8a8a, + 246: 0xff949494, + 247: 0xff9e9e9e, + 248: 0xffa8a8a8, + 249: 0xffb2b2b2, + 250: 0xffbcbcbc, + 251: 0xffc6c6c6, + 252: 0xffd0d0d0, + 253: 0xffdadada, + 254: 0xffe4e4e4, + 255: 0xffeeeeee, +}); diff --git a/lib/src/terminal/src/ui/paragraph_cache.dart b/lib/src/terminal/src/ui/paragraph_cache.dart new file mode 100644 index 00000000..0fb5bfbb --- /dev/null +++ b/lib/src/terminal/src/ui/paragraph_cache.dart @@ -0,0 +1,58 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:collection'; +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +class _LruCache { + _LruCache(this._maxSize); + final int _maxSize; + final _map = LinkedHashMap(); + + V? operator [](K key) { + final value = _map.remove(key); + if (value != null) _map[key] = value; + return value; + } + + void operator []=(K key, V value) { + _map.remove(key); + _map[key] = value; + while (_map.length > _maxSize) { + _map.remove(_map.keys.first); + } + } + + void clear() => _map.clear(); + int get length => _map.length; +} + +class ParagraphCache { + ParagraphCache(int maximumSize) : _cache = _LruCache(maximumSize); + + final _LruCache _cache; + + Paragraph? getLayoutFromCache(int key) => _cache[key]; + + Paragraph performAndCacheLayout( + String text, + TextStyle style, + TextScaler textScaler, + int key, + ) { + final builder = ParagraphBuilder(style.getParagraphStyle()); + builder.pushStyle(style.getTextStyle(textScaler: textScaler)); + builder.addText(text); + + final paragraph = builder.build(); + paragraph.layout(ParagraphConstraints(width: double.infinity)); + + _cache[key] = paragraph; + return paragraph; + } + + void clear() => _cache.clear(); + + int get length => _cache.length; +} diff --git a/lib/src/terminal/src/ui/pointer_input.dart b/lib/src/terminal/src/ui/pointer_input.dart new file mode 100644 index 00000000..ff165e23 --- /dev/null +++ b/lib/src/terminal/src/ui/pointer_input.dart @@ -0,0 +1,31 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum PointerInput { + /// Taps / buttons presses & releases. + tap, + + /// Scroll / mouse wheels events. + scroll, + + /// Drag events, a pointer is in a down state and dragged across the terminal. + drag, + + /// Move events, a pointer is in an up state and moved across the terminal. + move, +} + +class PointerInputs { + final Set inputs; + + const PointerInputs(this.inputs); + + const PointerInputs.none() : inputs = const {}; + + const PointerInputs.all() + : inputs = const { + PointerInput.tap, + PointerInput.scroll, + PointerInput.drag, + PointerInput.move, + }; +} diff --git a/lib/src/terminal/src/ui/render.dart b/lib/src/terminal/src/ui/render.dart new file mode 100644 index 00000000..19f77db6 --- /dev/null +++ b/lib/src/terminal/src/ui/render.dart @@ -0,0 +1,554 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:math' show max; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart'; +import 'package:clide/src/terminal/src/core/buffer/range.dart'; +import 'package:clide/src/terminal/src/core/buffer/segment.dart'; +import 'package:clide/src/terminal/src/core/mouse/button.dart'; +import 'package:clide/src/terminal/src/core/mouse/button_state.dart'; +import 'package:clide/src/terminal/src/terminal.dart'; +import 'package:clide/src/terminal/src/ui/controller.dart'; +import 'package:clide/src/terminal/src/ui/cursor_type.dart'; +import 'package:clide/src/terminal/src/ui/painter.dart'; +import 'package:clide/src/terminal/src/ui/selection_mode.dart'; +import 'package:clide/src/terminal/src/ui/terminal_size.dart'; +import 'package:clide/src/terminal/src/ui/terminal_text_style.dart'; +import 'package:clide/src/terminal/src/ui/terminal_theme.dart'; + +typedef EditableRectCallback = void Function(Rect rect, Rect caretRect); + +class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + RenderTerminal({ + required Terminal terminal, + required TerminalController controller, + required ViewportOffset offset, + required EdgeInsets padding, + required bool autoResize, + required TerminalStyle textStyle, + required TextScaler textScaler, + required TerminalTheme theme, + required FocusNode focusNode, + required TerminalCursorType cursorType, + required bool alwaysShowCursor, + EditableRectCallback? onEditableRect, + String? composingText, + }) : _terminal = terminal, + _controller = controller, + _offset = offset, + _padding = padding, + _autoResize = autoResize, + _focusNode = focusNode, + _cursorType = cursorType, + _alwaysShowCursor = alwaysShowCursor, + _onEditableRect = onEditableRect, + _composingText = composingText, + _painter = TerminalPainter( + theme: theme, + textStyle: textStyle, + textScaler: textScaler, + ); + + Terminal _terminal; + set terminal(Terminal terminal) { + if (_terminal == terminal) return; + if (attached) _terminal.removeListener(_onTerminalChange); + _terminal = terminal; + if (attached) _terminal.addListener(_onTerminalChange); + _resizeTerminalIfNeeded(); + markNeedsLayout(); + } + + TerminalController _controller; + set controller(TerminalController controller) { + if (_controller == controller) return; + if (attached) _controller.removeListener(_onControllerUpdate); + _controller = controller; + if (attached) _controller.addListener(_onControllerUpdate); + markNeedsLayout(); + } + + ViewportOffset _offset; + set offset(ViewportOffset value) { + if (value == _offset) return; + if (attached) _offset.removeListener(_onScroll); + _offset = value; + if (attached) _offset.addListener(_onScroll); + markNeedsLayout(); + } + + EdgeInsets _padding; + set padding(EdgeInsets value) { + if (value == _padding) return; + _padding = value; + markNeedsLayout(); + } + + bool _autoResize; + set autoResize(bool value) { + if (value == _autoResize) return; + _autoResize = value; + markNeedsLayout(); + } + + set textStyle(TerminalStyle value) { + if (value == _painter.textStyle) return; + _painter.textStyle = value; + markNeedsLayout(); + } + + set textScaler(TextScaler value) { + if (value == _painter.textScaler) return; + _painter.textScaler = value; + markNeedsLayout(); + } + + set theme(TerminalTheme value) { + if (value == _painter.theme) return; + _painter.theme = value; + markNeedsPaint(); + } + + FocusNode _focusNode; + set focusNode(FocusNode value) { + if (value == _focusNode) return; + if (attached) _focusNode.removeListener(_onFocusChange); + _focusNode = value; + if (attached) _focusNode.addListener(_onFocusChange); + markNeedsPaint(); + } + + TerminalCursorType _cursorType; + set cursorType(TerminalCursorType value) { + if (value == _cursorType) return; + _cursorType = value; + markNeedsPaint(); + } + + bool _alwaysShowCursor; + set alwaysShowCursor(bool value) { + if (value == _alwaysShowCursor) return; + _alwaysShowCursor = value; + markNeedsPaint(); + } + + EditableRectCallback? _onEditableRect; + set onEditableRect(EditableRectCallback? value) { + if (value == _onEditableRect) return; + _onEditableRect = value; + markNeedsLayout(); + } + + String? _composingText; + set composingText(String? value) { + if (value == _composingText) return; + _composingText = value; + markNeedsPaint(); + } + + TerminalSize? _viewportSize; + + final TerminalPainter _painter; + + var _stickToBottom = true; + + void _onScroll() { + _stickToBottom = _scrollOffset >= _maxScrollExtent; + markNeedsLayout(); + _notifyEditableRect(); + } + + void _onFocusChange() { + markNeedsPaint(); + } + + void _onTerminalChange() { + markNeedsLayout(); + _notifyEditableRect(); + } + + void _onControllerUpdate() { + markNeedsLayout(); + } + + @override + final isRepaintBoundary = true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _offset.addListener(_onScroll); + _terminal.addListener(_onTerminalChange); + _controller.addListener(_onControllerUpdate); + _focusNode.addListener(_onFocusChange); + } + + @override + void detach() { + super.detach(); + _offset.removeListener(_onScroll); + _terminal.removeListener(_onTerminalChange); + _controller.removeListener(_onControllerUpdate); + _focusNode.removeListener(_onFocusChange); + } + + @override + bool hitTestSelf(Offset position) { + return true; + } + + @override + void systemFontsDidChange() { + _painter.clearFontCache(); + super.systemFontsDidChange(); + } + + @override + void performLayout() { + size = constraints.biggest; + + _updateViewportSize(); + + _updateScrollOffset(); + + if (_stickToBottom) { + _offset.correctBy(_maxScrollExtent - _scrollOffset); + } + } + + /// Total height of the terminal in pixels. Includes scrollback buffer. + double get _terminalHeight => + _terminal.buffer.lines.length * _painter.cellSize.height; + + /// The distance from the top of the terminal to the top of the viewport. + // double get _scrollOffset => _offset.pixels; + double get _scrollOffset { + // return _offset.pixels ~/ _painter.cellSize.height * _painter.cellSize.height; + return _offset.pixels; + } + + /// The height of a terminal line in pixels. This includes the line spacing. + /// Height of the entire terminal is expected to be a multiple of this value. + double get lineHeight => _painter.cellSize.height; + + /// Get the top-left corner of the cell at [cellOffset] in pixels. + Offset getOffset(CellOffset cellOffset) { + final row = cellOffset.y; + final col = cellOffset.x; + final x = col * _painter.cellSize.width; + final y = row * _painter.cellSize.height; + return Offset(x + _padding.left, y + _padding.top - _scrollOffset); + } + + /// Get the [CellOffset] of the cell that [offset] is in. + CellOffset getCellOffset(Offset offset) { + final x = offset.dx - _padding.left; + final y = offset.dy - _padding.top + _scrollOffset; + final row = y ~/ _painter.cellSize.height; + final col = x ~/ _painter.cellSize.width; + return CellOffset( + col.clamp(0, _terminal.viewWidth - 1), + row.clamp(0, _terminal.buffer.lines.length - 1), + ); + } + + /// Selects entire words in the terminal that contains [from] and [to]. + void selectWord(Offset from, [Offset? to]) { + final fromOffset = getCellOffset(from); + final fromBoundary = _terminal.buffer.getWordBoundary(fromOffset); + if (fromBoundary == null) return; + if (to == null) { + _controller.setSelection( + _terminal.buffer.createAnchorFromOffset(fromBoundary.begin), + _terminal.buffer.createAnchorFromOffset(fromBoundary.end), + mode: SelectionMode.line, + ); + } else { + final toOffset = getCellOffset(to); + final toBoundary = _terminal.buffer.getWordBoundary(toOffset); + if (toBoundary == null) return; + final range = fromBoundary.merge(toBoundary); + _controller.setSelection( + _terminal.buffer.createAnchorFromOffset(range.begin), + _terminal.buffer.createAnchorFromOffset(range.end), + mode: SelectionMode.line, + ); + } + } + + /// Selects characters in the terminal that starts from [from] to [to]. At + /// least one cell is selected even if [from] and [to] are same. + void selectCharacters(Offset from, [Offset? to]) { + final fromPosition = getCellOffset(from); + if (to == null) { + _controller.setSelection( + _terminal.buffer.createAnchorFromOffset(fromPosition), + _terminal.buffer.createAnchorFromOffset(fromPosition), + ); + } else { + var toPosition = getCellOffset(to); + if (toPosition.x >= fromPosition.x) { + toPosition = CellOffset(toPosition.x + 1, toPosition.y); + } + _controller.setSelection( + _terminal.buffer.createAnchorFromOffset(fromPosition), + _terminal.buffer.createAnchorFromOffset(toPosition), + ); + } + } + + /// Send a mouse event at [offset] with [button] being currently in [buttonState]. + bool mouseEvent( + TerminalMouseButton button, + TerminalMouseButtonState buttonState, + Offset offset, + ) { + final position = getCellOffset(offset); + return _terminal.mouseInput(button, buttonState, position); + } + + void _notifyEditableRect() { + final cursor = localToGlobal(cursorOffset); + + final rect = Rect.fromLTRB( + cursor.dx, + cursor.dy, + size.width, + cursor.dy + _painter.cellSize.height, + ); + + final caretRect = cursor & _painter.cellSize; + + _onEditableRect?.call(rect, caretRect); + } + + /// Update the viewport size in cells based on the current widget size in + /// pixels. + void _updateViewportSize() { + if (size <= _painter.cellSize) { + return; + } + + final viewportSize = TerminalSize( + size.width ~/ _painter.cellSize.width, + _viewportHeight ~/ _painter.cellSize.height, + ); + + if (_viewportSize != viewportSize) { + _viewportSize = viewportSize; + _resizeTerminalIfNeeded(); + } + } + + /// Notify the underlying terminal that the viewport size has changed. + void _resizeTerminalIfNeeded() { + if (_autoResize && _viewportSize != null) { + _terminal.resize( + _viewportSize!.width, + _viewportSize!.height, + _painter.cellSize.width.round(), + _painter.cellSize.height.round(), + ); + } + } + + /// Update the scroll offset based on the current terminal state. This should + /// be called in [performLayout] after the viewport size has been updated. + void _updateScrollOffset() { + _offset.applyViewportDimension(_viewportHeight); + _offset.applyContentDimensions(0, _maxScrollExtent); + } + + bool get _isComposingText { + return _composingText != null && _composingText!.isNotEmpty; + } + + bool get _shouldShowCursor { + return _terminal.cursorVisibleMode || _alwaysShowCursor || _isComposingText; + } + + double get _viewportHeight { + return size.height - _padding.vertical; + } + + double get _maxScrollExtent { + return max(_terminalHeight - _viewportHeight, 0.0); + } + + double get _lineOffset { + return -_scrollOffset + _padding.top; + } + + /// The offset of the cursor from the top left corner of this render object. + Offset get cursorOffset { + return Offset( + _terminal.buffer.cursorX * _painter.cellSize.width, + _terminal.buffer.absoluteCursorY * _painter.cellSize.height + _lineOffset, + ); + } + + Size get cellSize { + return _painter.cellSize; + } + + @override + void paint(PaintingContext context, Offset offset) { + _paint(context, offset); + context.setWillChangeHint(); + } + + void _paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + + final lines = _terminal.buffer.lines; + final charHeight = _painter.cellSize.height; + + final firstLineOffset = _scrollOffset - _padding.top; + final lastLineOffset = _scrollOffset + size.height + _padding.bottom; + + final firstLine = firstLineOffset ~/ charHeight; + final lastLine = lastLineOffset ~/ charHeight; + + final effectFirstLine = firstLine.clamp(0, lines.length - 1); + final effectLastLine = lastLine.clamp(0, lines.length - 1); + + for (var i = effectFirstLine; i <= effectLastLine; i++) { + _painter.paintLine( + canvas, + offset.translate(0, (i * charHeight + _lineOffset).truncateToDouble()), + lines[i], + ); + } + + if (_terminal.buffer.absoluteCursorY >= effectFirstLine && + _terminal.buffer.absoluteCursorY <= effectLastLine) { + if (_isComposingText) { + _paintComposingText(canvas, offset + cursorOffset); + } + + if (_shouldShowCursor) { + _painter.paintCursor( + canvas, + offset + cursorOffset, + cursorType: _cursorType, + hasFocus: _focusNode.hasFocus, + ); + } + } + + _paintHighlights( + canvas, + _controller.highlights, + effectFirstLine, + effectLastLine, + ); + + if (_controller.selection != null) { + _paintSelection( + canvas, + _controller.selection!, + effectFirstLine, + effectLastLine, + ); + } + } + + /// Paints the text that is currently being composed in IME to [canvas] at + /// [offset]. [offset] is usually the cursor position. + void _paintComposingText(Canvas canvas, Offset offset) { + final composingText = _composingText; + if (composingText == null) { + return; + } + + final style = _painter.textStyle.toTextStyle( + color: _painter.resolveForegroundColor(_terminal.cursor.foreground), + backgroundColor: _painter.theme.background, + underline: true, + ); + + final builder = ParagraphBuilder(style.getParagraphStyle()); + builder.addPlaceholder( + offset.dx, + _painter.cellSize.height, + PlaceholderAlignment.middle, + ); + builder.pushStyle( + style.getTextStyle(textScaler: _painter.textScaler), + ); + builder.addText(composingText); + + final paragraph = builder.build(); + paragraph.layout(ParagraphConstraints(width: size.width)); + + canvas.drawParagraph(paragraph, Offset(0, offset.dy)); + } + + void _paintSelection( + Canvas canvas, + BufferRange selection, + int firstLine, + int lastLine, + ) { + for (final segment in selection.toSegments()) { + if (segment.line >= _terminal.buffer.lines.length) { + break; + } + + if (segment.line < firstLine) { + continue; + } + + if (segment.line > lastLine) { + break; + } + + _paintSegment(canvas, segment, _painter.theme.selection); + } + } + + void _paintHighlights( + Canvas canvas, + List highlights, + int firstLine, + int lastLine, + ) { + for (var highlight in _controller.highlights) { + final range = highlight.range?.normalized; + + if (range == null || + range.begin.y > lastLine || + range.end.y < firstLine) { + continue; + } + + for (var segment in range.toSegments()) { + if (segment.line < firstLine) { + continue; + } + + if (segment.line > lastLine) { + break; + } + + _paintSegment(canvas, segment, highlight.color); + } + } + } + + @pragma('vm:prefer-inline') + void _paintSegment(Canvas canvas, BufferSegment segment, Color color) { + final start = segment.start ?? 0; + final end = segment.end ?? _terminal.viewWidth; + + final startOffset = Offset( + start * _painter.cellSize.width, + segment.line * _painter.cellSize.height + _lineOffset, + ); + + _painter.paintHighlight(canvas, startOffset, end - start, color); + } +} diff --git a/lib/src/terminal/src/ui/scroll_handler.dart b/lib/src/terminal/src/ui/scroll_handler.dart new file mode 100644 index 00000000..8159ae87 --- /dev/null +++ b/lib/src/terminal/src/ui/scroll_handler.dart @@ -0,0 +1,102 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/terminal.dart'; + +class TerminalScrollGestureHandler extends StatefulWidget { + const TerminalScrollGestureHandler({ + super.key, + required this.terminal, + required this.getCellOffset, + required this.getLineHeight, + this.simulateScroll = true, + required this.child, + }); + + final Terminal terminal; + final CellOffset Function(Offset) getCellOffset; + final double Function() getLineHeight; + final bool simulateScroll; + final Widget child; + + @override + State createState() => + _TerminalScrollGestureHandlerState(); +} + +class _TerminalScrollGestureHandlerState + extends State { + var isAltBuffer = false; + var _lastPointerPosition = Offset.zero; + + @override + void initState() { + widget.terminal.addListener(_onTerminalUpdated); + isAltBuffer = widget.terminal.isUsingAltBuffer; + super.initState(); + } + + @override + void dispose() { + widget.terminal.removeListener(_onTerminalUpdated); + super.dispose(); + } + + @override + void didUpdateWidget(covariant TerminalScrollGestureHandler oldWidget) { + if (oldWidget.terminal != widget.terminal) { + oldWidget.terminal.removeListener(_onTerminalUpdated); + widget.terminal.addListener(_onTerminalUpdated); + isAltBuffer = widget.terminal.isUsingAltBuffer; + } + super.didUpdateWidget(oldWidget); + } + + void _onTerminalUpdated() { + if (isAltBuffer != widget.terminal.isUsingAltBuffer) { + isAltBuffer = widget.terminal.isUsingAltBuffer; + setState(() {}); + } + } + + void _sendScrollEvent(bool up) { + final position = widget.getCellOffset(_lastPointerPosition); + + final handled = widget.terminal.mouseInput( + up ? TerminalMouseButton.wheelUp : TerminalMouseButton.wheelDown, + TerminalMouseButtonState.down, + position, + ); + + if (!handled && widget.simulateScroll) { + widget.terminal.keyInput( + up ? TerminalKey.arrowUp : TerminalKey.arrowDown, + ); + } + } + + void _onPointerSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent) return; + _lastPointerPosition = event.position; + final lineHeight = widget.getLineHeight(); + if (lineHeight <= 0) return; + final lines = (event.scrollDelta.dy / lineHeight).round().clamp(-5, 5); + for (var i = 0; i < lines.abs(); i++) { + _sendScrollEvent(lines < 0); + } + } + + @override + Widget build(BuildContext context) { + if (!isAltBuffer) return widget.child; + + // Intercept scroll at the pointer level so the inner Scrollable + // (normal buffer) never sees the event in alt-buffer mode. + return Listener( + onPointerSignal: _onPointerSignal, + onPointerDown: (event) => _lastPointerPosition = event.position, + child: widget.child, + ); + } +} diff --git a/lib/src/terminal/src/ui/selection_mode.dart b/lib/src/terminal/src/ui/selection_mode.dart new file mode 100644 index 00000000..222c2891 --- /dev/null +++ b/lib/src/terminal/src/ui/selection_mode.dart @@ -0,0 +1,7 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +enum SelectionMode { + line, + + block, +} diff --git a/lib/src/terminal/src/ui/shortcut/actions.dart b/lib/src/terminal/src/ui/shortcut/actions.dart new file mode 100644 index 00000000..ca98944b --- /dev/null +++ b/lib/src/terminal/src/ui/shortcut/actions.dart @@ -0,0 +1,73 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/src/terminal.dart'; +import 'package:clide/src/terminal/src/ui/controller.dart'; +import 'package:clide/src/terminal/src/ui/selection_mode.dart'; + +class TerminalActions extends StatelessWidget { + const TerminalActions({ + super.key, + required this.terminal, + required this.controller, + required this.child, + }); + + final Terminal terminal; + + final TerminalController controller; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Actions( + actions: { + PasteTextIntent: CallbackAction( + onInvoke: (intent) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text != null) { + terminal.paste(text); + controller.clearSelection(); + } + return null; + }, + ), + CopySelectionTextIntent: CallbackAction( + onInvoke: (intent) async { + final selection = controller.selection; + + if (selection == null) { + return; + } + + final text = terminal.buffer.getText(selection); + + await Clipboard.setData(ClipboardData(text: text)); + + return null; + }, + ), + SelectAllTextIntent: CallbackAction( + onInvoke: (intent) { + controller.setSelection( + terminal.buffer.createAnchor( + 0, + terminal.buffer.height - terminal.viewHeight, + ), + terminal.buffer.createAnchor( + terminal.viewWidth, + terminal.buffer.height - 1, + ), + mode: SelectionMode.line, + ); + return null; + }, + ), + }, + child: child, + ); + } +} diff --git a/lib/src/terminal/src/ui/shortcut/shortcuts.dart b/lib/src/terminal/src/ui/shortcut/shortcuts.dart new file mode 100644 index 00000000..a0ebc4f8 --- /dev/null +++ b/lib/src/terminal/src/ui/shortcut/shortcuts.dart @@ -0,0 +1,36 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +Map get defaultTerminalShortcuts { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return _defaultShortcuts; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _defaultAppleShortcuts; + } +} + +final _defaultShortcuts = { + SingleActivator(LogicalKeyboardKey.keyC, control: true, shift: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, control: true): + const PasteTextIntent(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyA, control: true): + const SelectAllTextIntent(SelectionChangedCause.keyboard), +}; + +final _defaultAppleShortcuts = { + SingleActivator(LogicalKeyboardKey.keyC, meta: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, meta: true): + const PasteTextIntent(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyA, meta: true): + const SelectAllTextIntent(SelectionChangedCause.keyboard), +}; diff --git a/lib/src/terminal/src/ui/terminal_size.dart b/lib/src/terminal/src/ui/terminal_size.dart new file mode 100644 index 00000000..59070c53 --- /dev/null +++ b/lib/src/terminal/src/ui/terminal_size.dart @@ -0,0 +1,26 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +class TerminalSize { + final int width; + + final int height; + + const TerminalSize(this.width, this.height); + + @override + String toString() => 'TerminalSize($width, $height)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! TerminalSize) { + return false; + } + return other.width == width && other.height == height; + } + + @override + int get hashCode => width.hashCode ^ height.hashCode; +} diff --git a/lib/src/terminal/src/ui/terminal_text_style.dart b/lib/src/terminal/src/ui/terminal_text_style.dart new file mode 100644 index 00000000..6cbe8ba3 --- /dev/null +++ b/lib/src/terminal/src/ui/terminal_text_style.dart @@ -0,0 +1,89 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; + +const _kDefaultFontSize = 13.0; + +const _kDefaultHeight = 1.2; + +const _kDefaultFontFamily = 'monospace'; + +const _kDefaultFontFamilyFallback = [ + 'Menlo', + 'Monaco', + 'Consolas', + 'Liberation Mono', + 'Courier New', + 'Noto Sans Mono CJK SC', + 'Noto Sans Mono CJK TC', + 'Noto Sans Mono CJK KR', + 'Noto Sans Mono CJK JP', + 'Noto Sans Mono CJK HK', + 'Noto Color Emoji', + 'Noto Sans Symbols', + 'monospace', + 'sans-serif', +]; + +class TerminalStyle { + const TerminalStyle({ + this.fontSize = _kDefaultFontSize, + this.height = _kDefaultHeight, + this.fontFamily = _kDefaultFontFamily, + this.fontFamilyFallback = _kDefaultFontFamilyFallback, + }); + + factory TerminalStyle.fromTextStyle(TextStyle textStyle) { + return TerminalStyle( + fontSize: textStyle.fontSize ?? _kDefaultFontSize, + height: textStyle.height ?? _kDefaultHeight, + fontFamily: textStyle.fontFamily ?? + textStyle.fontFamilyFallback?.first ?? + _kDefaultFontFamily, + fontFamilyFallback: + textStyle.fontFamilyFallback ?? _kDefaultFontFamilyFallback, + ); + } + + final double fontSize; + + final double height; + + final String fontFamily; + + final List fontFamilyFallback; + + TextStyle toTextStyle({ + Color? color, + Color? backgroundColor, + bool bold = false, + bool italic = false, + bool underline = false, + }) { + return TextStyle( + fontSize: fontSize, + height: height, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + color: color, + backgroundColor: backgroundColor, + fontWeight: bold ? FontWeight.bold : FontWeight.normal, + fontStyle: italic ? FontStyle.italic : FontStyle.normal, + decoration: underline ? TextDecoration.underline : TextDecoration.none, + ); + } + + TerminalStyle copyWith({ + double? fontSize, + double? height, + String? fontFamily, + List? fontFamilyFallback, + }) { + return TerminalStyle( + fontSize: fontSize ?? this.fontSize, + height: height ?? this.height, + fontFamily: fontFamily ?? this.fontFamily, + fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback, + ); + } +} diff --git a/lib/src/terminal/src/ui/terminal_theme.dart b/lib/src/terminal/src/ui/terminal_theme.dart new file mode 100644 index 00000000..0c0f8993 --- /dev/null +++ b/lib/src/terminal/src/ui/terminal_theme.dart @@ -0,0 +1,59 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; + +class TerminalTheme { + const TerminalTheme({ + required this.cursor, + required this.selection, + required this.foreground, + required this.background, + required this.black, + required this.white, + required this.red, + required this.green, + required this.yellow, + required this.blue, + required this.magenta, + required this.cyan, + required this.brightBlack, + required this.brightRed, + required this.brightGreen, + required this.brightYellow, + required this.brightBlue, + required this.brightMagenta, + required this.brightCyan, + required this.brightWhite, + required this.searchHitBackground, + required this.searchHitBackgroundCurrent, + required this.searchHitForeground, + }); + + final Color cursor; + final Color selection; + + final Color foreground; + final Color background; + + final Color black; + final Color red; + final Color green; + final Color yellow; + final Color blue; + final Color magenta; + final Color cyan; + final Color white; + + final Color brightBlack; + final Color brightRed; + final Color brightGreen; + final Color brightYellow; + final Color brightBlue; + final Color brightMagenta; + final Color brightCyan; + final Color brightWhite; + + final Color searchHitBackground; + final Color searchHitBackgroundCurrent; + final Color searchHitForeground; +} diff --git a/lib/src/terminal/src/ui/themes.dart b/lib/src/terminal/src/ui/themes.dart new file mode 100644 index 00000000..6c1d958d --- /dev/null +++ b/lib/src/terminal/src/ui/themes.dart @@ -0,0 +1,58 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'package:flutter/widgets.dart'; +import 'package:clide/src/terminal/src/ui/terminal_theme.dart'; + +class TerminalThemes { + static const defaultTheme = TerminalTheme( + cursor: Color(0XAAAEAFAD), + selection: Color(0XAAAEAFAD), + foreground: Color(0XFFCCCCCC), + background: Color(0XFF1E1E1E), + black: Color(0XFF000000), + red: Color(0XFFCD3131), + green: Color(0XFF0DBC79), + yellow: Color(0XFFE5E510), + blue: Color(0XFF2472C8), + magenta: Color(0XFFBC3FBC), + cyan: Color(0XFF11A8CD), + white: Color(0XFFE5E5E5), + brightBlack: Color(0XFF666666), + brightRed: Color(0XFFF14C4C), + brightGreen: Color(0XFF23D18B), + brightYellow: Color(0XFFF5F543), + brightBlue: Color(0XFF3B8EEA), + brightMagenta: Color(0XFFD670D6), + brightCyan: Color(0XFF29B8DB), + brightWhite: Color(0XFFFFFFFF), + searchHitBackground: Color(0XFFFFFF2B), + searchHitBackgroundCurrent: Color(0XFF31FF26), + searchHitForeground: Color(0XFF000000), + ); + + static const whiteOnBlack = TerminalTheme( + cursor: Color(0XFFAEAFAD), + selection: Color(0XFFAEAFAD), + foreground: Color(0XFFFFFFFF), + background: Color(0XFF000000), + black: Color(0XFF000000), + red: Color(0XFFCD3131), + green: Color(0XFF0DBC79), + yellow: Color(0XFFE5E510), + blue: Color(0XFF2472C8), + magenta: Color(0XFFBC3FBC), + cyan: Color(0XFF11A8CD), + white: Color(0XFFE5E5E5), + brightBlack: Color(0XFF666666), + brightRed: Color(0XFFF14C4C), + brightGreen: Color(0XFF23D18B), + brightYellow: Color(0XFFF5F543), + brightBlue: Color(0XFF3B8EEA), + brightMagenta: Color(0XFFD670D6), + brightCyan: Color(0XFF29B8DB), + brightWhite: Color(0XFFFFFFFF), + searchHitBackground: Color(0XFFFFFF2B), + searchHitBackgroundCurrent: Color(0XFF31FF26), + searchHitForeground: Color(0XFF000000), + ); +} diff --git a/lib/src/terminal/src/utils/ascii.dart b/lib/src/terminal/src/utils/ascii.dart new file mode 100644 index 00000000..b5758a0d --- /dev/null +++ b/lib/src/terminal/src/utils/ascii.dart @@ -0,0 +1,405 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +// ignore_for_file: constant_identifier_names + +abstract class Ascii { + /* + * Helper functions + */ + + static bool isNonPrintable(int c) { + return c < 32 || c == 127; + } + + /* + * Non-printable ASCII characters + */ + + /// Null character + static const NULL = 00; + + /// Start of Header + static const SOH = 01; + + /// Start of Text + static const STX = 02; + + /// End of Text, hearts card suit + static const ETX = 03; + + /// End of Transmission, diamonds card suit + static const EOT = 04; + + /// Enquiry, clubs card suit + static const ENQ = 05; + + /// Acknowledgement, spade card suit + static const ACK = 06; + + /// Bell + static const BEL = 07; + + /// Backspace + static const BS = 08; + + /// Horizontal Tab + static const HT = 09; + + /// Line feed + static const LF = 10; + + /// Vertical Tab, male symbol, symbol for Mars + static const VT = 11; + + /// Form feed, female symbol, symbol for Venus + static const FF = 12; + + /// Carriage return + static const CR = 13; + + /// Shift Out + static const SO = 14; + + /// Shift In + static const SI = 15; + + /// Data link escape + static const DLE = 16; + + /// Device control 1 + static const DC1 = 17; + + /// Device control 2 + static const DC2 = 18; + + /// Device control 3 + static const DC3 = 19; + + /// Device control 4 + static const DC4 = 20; + + /// NAK Negative-acknowledge + static const NAK = 21; + + /// Synchronous idle + static const SYN = 22; + + /// End of trans. block + static const ETB = 23; + + /// Cancel + static const CAN = 24; + + /// End of medium + static const EM = 25; + + /// Substitute + static const SUB = 26; + + /// Escape + static const ESC = 27; + + /// File separator + static const FS = 28; + + /// Group separator + static const GS = 29; + + /// Record separator + static const RS = 30; + + /// Unit separator + static const US = 31; + + /// Delete + static const DEL = 127; + + /* + * Printable ASCII characters + */ + + /// Space " " + static const space = 32; + + /// Exclamation mark "!" + static const exclamationMark = 33; + + /// Double quotes '"' + static const doubleQuotes = 34; + + /// Number sign '#' + static const numberSign = 35; + + /// Dollar sign '$' + static const dollarSign = 36; + + /// Percent sign '%' + static const percentSign = 37; + + /// Ampersand '&' + static const ampersand = 38; + + /// Single quote "'" + static const singleQuote = 39; + + /// round brackets or parentheses, opening round bracket '(' + static const openParentheses = 40; + + /// parentheses or round brackets, closing parentheses ')' + static const closeParentheses = 41; + + /// Asterisk '*' + static const asterisk = 42; + + /// Plus sign '+' + static const plus = 43; + + /// Comma "," + static const comma = 44; + + /// Hyphen , minus sign '-' + static const minus = 45; + + /// Dot, full stop '.' + static const dot = 46; + + /// Slash , forward slash , fraction bar , division slash '/' + static const slash = 47; + + /// number zero + static const num0 = 48; + + /// number one + static const num1 = 49; + + /// number two + static const num2 = 50; + + /// number three + static const num3 = 51; + + /// number four + static const num4 = 52; + + /// number five + static const num5 = 53; + + /// number six + static const num6 = 54; + + /// number seven + static const num7 = 55; + + /// number eight + static const num8 = 56; + + /// number nine + static const num9 = 57; + + /// Colon ':' + static const colon = 58; + + /// Semicolon ';' + static const semicolon = 59; + + /// Less-than sign '<' + static const lessThan = 60; + + /// Equals sign '=' + static const equal = 61; + + /// Greater-than sign ; Inequality sign '>' + static const greaterThan = 62; + + /// Question mark '?' + static const questionMark = 63; + + /// At sign '@' + static const atSign = 64; + + /// Capital letter A + static const A = 65; + + /// Capital letter B + static const B = 66; + + /// Capital letter C + static const C = 67; + + /// Capital letter D + static const D = 68; + + /// Capital letter E + static const E = 69; + + /// Capital letter F + static const F = 70; + + /// Capital letter G + static const G = 71; + + /// Capital letter H + static const H = 72; + + /// Capital letter I + static const I = 73; + + /// Capital letter J + static const J = 74; + + /// Capital letter K + static const K = 75; + + /// Capital letter L + static const L = 76; + + /// Capital letter M + static const M = 77; + + /// Capital letter N + static const N = 78; + + /// Capital letter O + static const O = 79; + + /// Capital letter P + static const P = 80; + + /// Capital letter Q + static const Q = 81; + + /// Capital letter R + static const R = 82; + + /// Capital letter S + static const S = 83; + + /// Capital letter T + static const T = 84; + + /// Capital letter U + static const U = 85; + + /// Capital letter V + static const V = 86; + + /// Capital letter W + static const W = 87; + + /// Capital letter X + static const X = 88; + + /// Capital letter Y + static const Y = 89; + + /// Capital letter Z + static const Z = 90; + + /// square brackets or box brackets, opening bracket '[' + static const openBracket = 91; + + /// Backslash , reverse slash '\\' + static const backslash = 92; + + /// box brackets or square brackets, closing bracket ']' + static const closeBracket = 93; + + /// Circumflex accent or Caret '^' + static const caret = 94; + + /// underscore , understrike , underbar or low line '_' + static const underscore = 95; + + /// Grave accent '`' + static const graveAccent = 96; + + /// Lowercase letter a , minuscule a + static const a = 97; + + /// Lowercase letter b , minuscule b + static const b = 98; + + /// Lowercase letter c , minuscule c + static const c = 99; + + /// Lowercase letter d , minuscule d + static const d = 100; + + /// Lowercase letter e , minuscule e + static const e = 101; + + /// Lowercase letter f , minuscule f + static const f = 102; + + /// Lowercase letter g , minuscule g + static const g = 103; + + /// Lowercase letter h , minuscule h + static const h = 104; + + /// Lowercase letter i , minuscule i + static const i = 105; + + /// Lowercase letter j , minuscule j + static const j = 106; + + /// Lowercase letter k , minuscule k + static const k = 107; + + /// Lowercase letter l , minuscule l + static const l = 108; + + /// Lowercase letter m , minuscule m + static const m = 109; + + /// Lowercase letter n , minuscule n + static const n = 110; + + /// Lowercase letter o , minuscule o + static const o = 111; + + /// Lowercase letter p , minuscule p + static const p = 112; + + /// Lowercase letter q , minuscule q + static const q = 113; + + /// Lowercase letter r , minuscule r + static const r = 114; + + /// Lowercase letter s , minuscule s + static const s = 115; + + /// Lowercase letter t , minuscule t + static const t = 116; + + /// Lowercase letter u , minuscule u + static const u = 117; + + /// Lowercase letter v , minuscule v + static const v = 118; + + /// Lowercase letter w , minuscule w + static const w = 119; + + /// Lowercase letter x , minuscule x + static const x = 120; + + /// Lowercase letter y , minuscule y + static const y = 121; + + /// Lowercase letter z , minuscule z + static const z = 122; + + /// braces or curly brackets, opening braces '{' + static const openBrace = 123; + + /// vertical-bar, vbar, vertical line or vertical slash '|' + static const verticalBar = 124; + + /// curly brackets or braces, closing curly brackets '}' + static const closeBrace = 125; + + /// Tilde ; swung dash '~' + static const tilde = 126; +} diff --git a/lib/src/terminal/src/utils/byte_consumer.dart b/lib/src/terminal/src/utils/byte_consumer.dart new file mode 100644 index 00000000..7bc7d21a --- /dev/null +++ b/lib/src/terminal/src/utils/byte_consumer.dart @@ -0,0 +1,109 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +import 'dart:collection'; + +class ByteConsumer { + final _queue = ListQueue>(); + + final _consumed = ListQueue>(); + + var _currentOffset = 0; + + var _length = 0; + + var _totalConsumed = 0; + + void add(String data) { + if (data.isEmpty) return; + final runes = data.runes.toList(growable: false); + _queue.addLast(runes); + _length += runes.length; + } + + int peek() { + final data = _queue.first; + if (_currentOffset < data.length) { + return data[_currentOffset]; + } else { + final result = consume(); + rollback(); + return result; + } + } + + int consume() { + final data = _queue.first; + + if (_currentOffset >= data.length) { + _consumed.add(_queue.removeFirst()); + _currentOffset -= data.length; + return consume(); + } + + _length--; + _totalConsumed++; + return data[_currentOffset++]; + } + + /// Rolls back the last [n] call. + void rollback([int n = 1]) { + _currentOffset -= n; + _totalConsumed -= n; + _length += n; + while (_currentOffset < 0) { + final rollback = _consumed.removeLast(); + _queue.addFirst(rollback); + _currentOffset += rollback.length; + } + } + + /// Rolls back to the state when this consumer had [length] bytes. + void rollbackTo(int length) { + rollback(length - _length); + } + + int get length => _length; + + int get totalConsumed => _totalConsumed; + + bool get isEmpty => _length == 0; + + bool get isNotEmpty => _length != 0; + + /// Unreferences data blocks that have been consumed. After calling this + /// method, the consumer will not be able to roll back to consumed blocks. + void unrefConsumedBlocks() { + _consumed.clear(); + } + + /// Resets the consumer to its initial state. + void reset() { + _queue.clear(); + _consumed.clear(); + _currentOffset = 0; + _totalConsumed = 0; + _length = 0; + } +} + +// void main() { +// final consumer = ByteConsumer(); +// consumer.add(Uint8List.fromList([1, 2, 3])); +// consumer.add(Uint8List.fromList([4, 5, 6])); + +// while (consumer.isNotEmpty) { +// print(consumer.consume()); +// } + +// consumer.rollback(5); + +// while (consumer.isNotEmpty) { +// print(consumer.consume()); +// } + +// consumer.rollbackTo(3); + +// while (consumer.isNotEmpty) { +// print(consumer.consume()); +// } +// } diff --git a/lib/src/terminal/src/utils/char_code.dart b/lib/src/terminal/src/utils/char_code.dart new file mode 100644 index 00000000..333e5354 --- /dev/null +++ b/lib/src/terminal/src/utils/char_code.dart @@ -0,0 +1,7 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +extension StringCharCode on String { + int get charCode { + return codeUnitAt(0); + } +} diff --git a/lib/src/terminal/src/utils/circular_buffer.dart b/lib/src/terminal/src/utils/circular_buffer.dart new file mode 100644 index 00000000..e1e64a38 --- /dev/null +++ b/lib/src/terminal/src/utils/circular_buffer.dart @@ -0,0 +1,317 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +/// A circular buffer in which elements know their index in the buffer. +class IndexAwareCircularBuffer { + /// Creates a new circular list with the specified [maxLength]. + IndexAwareCircularBuffer(int maxLength) + : _array = List.filled(maxLength, null); + + /// The backing array for this list. Length is always equal to [maxLength]. + late List _array; + + /// The number of elements in the list. This is always less than or equal to + /// [maxLength]. + var _length = 0; + + /// The index of the first element in [_array]. + var _startIndex = 0; + + /// The start index of this list, including items that has been dropped in + /// overflow + var _absoluteStartIndex = 0; + + /// Gets the cyclic index for the specified regular index. The cyclic index + /// can then be used on the backing array to get the element associated with + /// the regular index. + @pragma('vm:prefer-inline') + int _getCyclicIndex(int index) { + return (_startIndex + index) % _array.length; + } + + /// Removes the element at [index] from the list. + @pragma('vm:prefer-inline') + void _dropChild(int index) { + final cyclicIndex = _getCyclicIndex(index); + _array[cyclicIndex]?._detach(); + _array[cyclicIndex] = null; + } + + /// Adds the specified [child] to the list at the specified [index]. + @pragma('vm:prefer-inline') + void _adoptChild(int index, T child) { + final cyclicIndex = _getCyclicIndex(index); + _array[cyclicIndex]?._detach(); + _array[cyclicIndex] = child.._attach(this, index); + } + + /// Moves the element at [fromIndex] to [toIndex]. Both indexes should be + /// less than [maxLength]. + @pragma('vm:prefer-inline') + void _moveChild(int fromIndex, int toIndex) { + final fromCyclicIndex = _getCyclicIndex(fromIndex); + final toCyclicIndex = _getCyclicIndex(toIndex); + _array[toCyclicIndex]?._detach(); + _array[toCyclicIndex] = _array[fromCyclicIndex]?.._move(toIndex); + _array[fromCyclicIndex] = null; + } + + /// Gets the element at the specified [index] in the list. + @pragma('vm:prefer-inline') + T? _getChild(int index) { + return _array[_getCyclicIndex(index)]; + } + + /// The number of elements that can be stored in the list. + int get maxLength { + return _array.length; + } + + /// Sets the number of elements that can be stored in the list. This operation + /// is relatively expensive, as it requires the backing array to be + /// reallocated. + set maxLength(int value) { + if (value <= 0) { + throw ArgumentError.value(value, 'value', "maxLength can't be negative!"); + } + + if (value == _array.length) return; + + // Reconstruct array, starting at index 0. Only transfer values from the + // indexes 0 to length. + final newArray = List.generate( + value, + (index) => index < _length ? _getChild(index) : null, + ); + + _startIndex = 0; + _array = newArray; + } + + /// Number of elements in the list. + int get length { + return _length; + } + + /// Iterates over the list and calls [callback] for each element. + void forEach(void Function(T item) callback) { + final length = _length; + for (int i = 0; i < length; i++) { + callback(_getChild(i)!); + } + } + + /// Gets the element at the specified [index] in the list. Throws if the + /// index is out of bounds. + T operator [](int index) { + RangeError.checkValueInInterval(index, 0, length - 1, 'index'); + return _getChild(index)!; + } + + /// Sets the element at the specified [index] in the list. Throws if the + /// index is out of bounds. + operator []=(int index, T value) { + RangeError.checkValueInInterval(index, 0, length - 1, 'index'); + _adoptChild(index, value); + } + + /// Removes all elements from the list. + void clear() { + for (var i = 0; i < _length; i++) { + _dropChild(i); + } + _startIndex = 0; + _length = 0; + } + + /// Adds all elements in [items] to the list. + void pushAll(Iterable items) { + for (var element in items) { + push(element); + } + } + + /// Adds [value] to the end of the list. May cause the first element to be + /// trimmed if the list is full. + void push(T value) { + _adoptChild(_length, value); + + if (_length == _array.length) { + // When the list is full, we trim the first element + _startIndex++; + _absoluteStartIndex++; + if (_startIndex == _array.length) { + _startIndex = 0; + } + } else { + // When the list is not full, we just increase the length + _length++; + } + } + + /// Removes and returns the last value on the list, throws if the list is + /// empty. + T pop() { + assert(_length > 0, 'Cannot pop from an empty list'); + final result = _getChild(_length - 1); + _dropChild(_length - 1); + _length--; + return result!; + } + + /// Deletes [count] elements starting at [index], shifting all elements after + /// [index] to the left. + void remove(int index, [int count = 1]) { + if (count > 0) { + if (index + count >= _length) { + count = _length - index; + } + for (var i = index; i < _length - count; i++) { + _moveChild(i + count, i); + } + for (var i = _length - count; i < _length; i++) { + _dropChild(i); + } + _length -= count; + } + } + + /// Inserts [item] at [index], shifting all elements after [index] to the + /// right. May cause the first element to be trimmed if the list is full. + void insert(int index, T item) { + RangeError.checkValueInInterval(index, 0, _length, 'index'); + + if (index == _length) { + return push(item); + } + + if (index == 0 && _length >= _array.length) { + // when something is inserted at index 0 and the list is full then + // the new value immediately gets removed => nothing changes + return; + } + + for (var i = _length - 1; i >= index; i--) { + _moveChild(i, i + 1); + } + + _adoptChild(index, item); + + if (_length >= _array.length) { + _startIndex += 1; + _absoluteStartIndex += 1; + } else { + _length++; + } + } + + /// Inserts [items] at [index] in order. + void insertAll(int index, List items) { + for (var i = items.length - 1; i >= 0; i--) { + insert(index, items[i]); + // when the list is full then we have to move the index down + // as newly inserted values remove values with a lower index + if (_length >= _array.length) { + index--; + if (index < 0) { + return; + } + } + } + } + + /// Removes [count] elements starting at [index], shifting all elements after + /// [index] to the left. + /// + /// This method is cheap since it does not actually modify the list, but + /// instead just adjusts the start index and length. + void trimStart(int count) { + if (count > _length) count = _length; + _startIndex += count; + _startIndex %= _array.length; + _length -= count; + } + + /// Replaces all elements in the list with [replacement]. + void replaceWith(List replacement) { + for (var i = 0; i < _length; i++) { + _dropChild(i); + } + + var copyStart = 0; + if (replacement.length > maxLength) { + copyStart = replacement.length - maxLength; + } + + for (var i = 0; i < copyStart; i++) { + _dropChild(i); + } + + final copyLength = replacement.length - copyStart; + for (var i = 0; i < copyLength; i++) { + _adoptChild(i, replacement[copyStart + i]); + } + + _startIndex = 0; + _length = copyLength; + } + + /// Replaces the element at [index] with [value] and returns the replaced + /// item. + T swap(int index, T value) { + final result = _getChild(index); + _adoptChild(index, value); + return result!; + } + + /// Whether adding another element would cause the first element to be + /// trimmed. + bool get isFull => length == maxLength; + + /// Returns a list containing all elements in the list. + List toList() { + return List.generate(length, (index) => this[index]); + } + + String debugDump() { + final buffer = StringBuffer(); + buffer.writeln('CircularList:'); + for (var i = 0; i < _length; i++) { + final child = _getChild(i); + buffer.writeln(' $i: $child'); + } + return buffer.toString(); + } +} + +mixin IndexedItem { + IndexAwareCircularBuffer? _owner; + + int? _absoluteIndex; + + /// The index of this item in the buffer. Must only be accessed when + /// [attached] is true. + int get index => _absoluteIndex! - _owner!._absoluteStartIndex; + + /// Whether this item is currently stored in a buffer. + bool get attached => _owner != null; + + /// Sets the owner and index of this item. This is called by the buffer when + /// the item is adopted. + void _attach(IndexAwareCircularBuffer owner, int index) { + _owner = owner; + _absoluteIndex = owner._absoluteStartIndex + index; + } + + /// Marks this item as detached from a buffer. This is called after the item + /// has been removed from the buffer. + void _detach() { + _owner = null; + _absoluteIndex = null; + } + + /// Moves this item to [newIndex] in the buffer. + void _move(int newIndex) { + assert(attached); + _absoluteIndex = _owner!._absoluteStartIndex + newIndex; + } +} diff --git a/lib/src/terminal/src/utils/hash_values.dart b/lib/src/terminal/src/utils/hash_values.dart new file mode 100644 index 00000000..66acdd27 --- /dev/null +++ b/lib/src/terminal/src/utils/hash_values.dart @@ -0,0 +1,113 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +class _HashEnd { + const _HashEnd(); +} + +const _HashEnd _hashEnd = _HashEnd(); + +class _Jenkins { + static int combine(int hash, Object? o) { + assert(o is! Iterable); + hash = 0x1fffffff & (hash + o.hashCode); + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +int hashValues( + Object? arg01, + Object? arg02, [ + Object? arg03 = _hashEnd, + Object? arg04 = _hashEnd, + Object? arg05 = _hashEnd, + Object? arg06 = _hashEnd, + Object? arg07 = _hashEnd, + Object? arg08 = _hashEnd, + Object? arg09 = _hashEnd, + Object? arg10 = _hashEnd, + Object? arg11 = _hashEnd, + Object? arg12 = _hashEnd, + Object? arg13 = _hashEnd, + Object? arg14 = _hashEnd, + Object? arg15 = _hashEnd, + Object? arg16 = _hashEnd, + Object? arg17 = _hashEnd, + Object? arg18 = _hashEnd, + Object? arg19 = _hashEnd, + Object? arg20 = _hashEnd, +]) { + int result = 0; + result = _Jenkins.combine(result, arg01); + result = _Jenkins.combine(result, arg02); + if (!identical(arg03, _hashEnd)) { + result = _Jenkins.combine(result, arg03); + if (!identical(arg04, _hashEnd)) { + result = _Jenkins.combine(result, arg04); + if (!identical(arg05, _hashEnd)) { + result = _Jenkins.combine(result, arg05); + if (!identical(arg06, _hashEnd)) { + result = _Jenkins.combine(result, arg06); + if (!identical(arg07, _hashEnd)) { + result = _Jenkins.combine(result, arg07); + if (!identical(arg08, _hashEnd)) { + result = _Jenkins.combine(result, arg08); + if (!identical(arg09, _hashEnd)) { + result = _Jenkins.combine(result, arg09); + if (!identical(arg10, _hashEnd)) { + result = _Jenkins.combine(result, arg10); + if (!identical(arg11, _hashEnd)) { + result = _Jenkins.combine(result, arg11); + if (!identical(arg12, _hashEnd)) { + result = _Jenkins.combine(result, arg12); + if (!identical(arg13, _hashEnd)) { + result = _Jenkins.combine(result, arg13); + if (!identical(arg14, _hashEnd)) { + result = _Jenkins.combine(result, arg14); + if (!identical(arg15, _hashEnd)) { + result = _Jenkins.combine(result, arg15); + if (!identical(arg16, _hashEnd)) { + result = _Jenkins.combine(result, arg16); + if (!identical(arg17, _hashEnd)) { + result = _Jenkins.combine(result, arg17); + if (!identical(arg18, _hashEnd)) { + result = _Jenkins.combine(result, arg18); + if (!identical(arg19, _hashEnd)) { + result = _Jenkins.combine(result, arg19); + if (!identical(arg20, _hashEnd)) { + result = _Jenkins.combine(result, arg20); + // I can see my house from here! + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + return _Jenkins.finish(result); +} + +int hashList(Iterable arguments) { + int result = 0; + for (Object argument in arguments) { + result = _Jenkins.combine(result, argument); + } + return _Jenkins.finish(result); +} diff --git a/lib/src/terminal/src/utils/lookup_table.dart b/lib/src/terminal/src/utils/lookup_table.dart new file mode 100644 index 00000000..e7e8808f --- /dev/null +++ b/lib/src/terminal/src/utils/lookup_table.dart @@ -0,0 +1,36 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +/// Fixed-size list based lookup table, optimized for small positive integer +/// keys. +class FastLookupTable { + FastLookupTable(Map data) { + var maxIndex = data.keys.first; + + for (var key in data.keys) { + if (key > maxIndex) { + maxIndex = key; + } + } + + _maxIndex = maxIndex; + + _table = List.filled(maxIndex + 1, null); + + for (var entry in data.entries) { + _table[entry.key] = entry.value; + } + } + + late final List _table; + late final int _maxIndex; + + T? operator [](int index) { + if (index > _maxIndex) { + return null; + } + + return _table[index]; + } + + int get maxIndex => _maxIndex; +} diff --git a/lib/src/terminal/src/utils/unicode_v11.dart b/lib/src/terminal/src/utils/unicode_v11.dart new file mode 100644 index 00000000..f2ca7929 --- /dev/null +++ b/lib/src/terminal/src/utils/unicode_v11.dart @@ -0,0 +1,514 @@ +// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory. + +// ignore_for_file: constant_identifier_names + +import 'dart:typed_data'; + +const BMP_COMBINING = [ + [0x0300, 0x036F], + [0x0483, 0x0489], + [0x0591, 0x05BD], + [0x05BF, 0x05BF], + [0x05C1, 0x05C2], + [0x05C4, 0x05C5], + [0x05C7, 0x05C7], + [0x0600, 0x0605], + [0x0610, 0x061A], + [0x061C, 0x061C], + [0x064B, 0x065F], + [0x0670, 0x0670], + [0x06D6, 0x06DD], + [0x06DF, 0x06E4], + [0x06E7, 0x06E8], + [0x06EA, 0x06ED], + [0x070F, 0x070F], + [0x0711, 0x0711], + [0x0730, 0x074A], + [0x07A6, 0x07B0], + [0x07EB, 0x07F3], + [0x07FD, 0x07FD], + [0x0816, 0x0819], + [0x081B, 0x0823], + [0x0825, 0x0827], + [0x0829, 0x082D], + [0x0859, 0x085B], + [0x08D3, 0x0902], + [0x093A, 0x093A], + [0x093C, 0x093C], + [0x0941, 0x0948], + [0x094D, 0x094D], + [0x0951, 0x0957], + [0x0962, 0x0963], + [0x0981, 0x0981], + [0x09BC, 0x09BC], + [0x09C1, 0x09C4], + [0x09CD, 0x09CD], + [0x09E2, 0x09E3], + [0x09FE, 0x09FE], + [0x0A01, 0x0A02], + [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], + [0x0A47, 0x0A48], + [0x0A4B, 0x0A4D], + [0x0A51, 0x0A51], + [0x0A70, 0x0A71], + [0x0A75, 0x0A75], + [0x0A81, 0x0A82], + [0x0ABC, 0x0ABC], + [0x0AC1, 0x0AC5], + [0x0AC7, 0x0AC8], + [0x0ACD, 0x0ACD], + [0x0AE2, 0x0AE3], + [0x0AFA, 0x0AFF], + [0x0B01, 0x0B01], + [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], + [0x0B41, 0x0B44], + [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], + [0x0B62, 0x0B63], + [0x0B82, 0x0B82], + [0x0BC0, 0x0BC0], + [0x0BCD, 0x0BCD], + [0x0C00, 0x0C00], + [0x0C04, 0x0C04], + [0x0C3E, 0x0C40], + [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], + [0x0C55, 0x0C56], + [0x0C62, 0x0C63], + [0x0C81, 0x0C81], + [0x0CBC, 0x0CBC], + [0x0CBF, 0x0CBF], + [0x0CC6, 0x0CC6], + [0x0CCC, 0x0CCD], + [0x0CE2, 0x0CE3], + [0x0D00, 0x0D01], + [0x0D3B, 0x0D3C], + [0x0D41, 0x0D44], + [0x0D4D, 0x0D4D], + [0x0D62, 0x0D63], + [0x0DCA, 0x0DCA], + [0x0DD2, 0x0DD4], + [0x0DD6, 0x0DD6], + [0x0E31, 0x0E31], + [0x0E34, 0x0E3A], + [0x0E47, 0x0E4E], + [0x0EB1, 0x0EB1], + [0x0EB4, 0x0EBC], + [0x0EC8, 0x0ECD], + [0x0F18, 0x0F19], + [0x0F35, 0x0F35], + [0x0F37, 0x0F37], + [0x0F39, 0x0F39], + [0x0F71, 0x0F7E], + [0x0F80, 0x0F84], + [0x0F86, 0x0F87], + [0x0F8D, 0x0F97], + [0x0F99, 0x0FBC], + [0x0FC6, 0x0FC6], + [0x102D, 0x1030], + [0x1032, 0x1037], + [0x1039, 0x103A], + [0x103D, 0x103E], + [0x1058, 0x1059], + [0x105E, 0x1060], + [0x1071, 0x1074], + [0x1082, 0x1082], + [0x1085, 0x1086], + [0x108D, 0x108D], + [0x109D, 0x109D], + [0x1160, 0x11FF], + [0x135D, 0x135F], + [0x1712, 0x1714], + [0x1732, 0x1734], + [0x1752, 0x1753], + [0x1772, 0x1773], + [0x17B4, 0x17B5], + [0x17B7, 0x17BD], + [0x17C6, 0x17C6], + [0x17C9, 0x17D3], + [0x17DD, 0x17DD], + [0x180B, 0x180E], + [0x1885, 0x1886], + [0x18A9, 0x18A9], + [0x1920, 0x1922], + [0x1927, 0x1928], + [0x1932, 0x1932], + [0x1939, 0x193B], + [0x1A17, 0x1A18], + [0x1A1B, 0x1A1B], + [0x1A56, 0x1A56], + [0x1A58, 0x1A5E], + [0x1A60, 0x1A60], + [0x1A62, 0x1A62], + [0x1A65, 0x1A6C], + [0x1A73, 0x1A7C], + [0x1A7F, 0x1A7F], + [0x1AB0, 0x1ABE], + [0x1B00, 0x1B03], + [0x1B34, 0x1B34], + [0x1B36, 0x1B3A], + [0x1B3C, 0x1B3C], + [0x1B42, 0x1B42], + [0x1B6B, 0x1B73], + [0x1B80, 0x1B81], + [0x1BA2, 0x1BA5], + [0x1BA8, 0x1BA9], + [0x1BAB, 0x1BAD], + [0x1BE6, 0x1BE6], + [0x1BE8, 0x1BE9], + [0x1BED, 0x1BED], + [0x1BEF, 0x1BF1], + [0x1C2C, 0x1C33], + [0x1C36, 0x1C37], + [0x1CD0, 0x1CD2], + [0x1CD4, 0x1CE0], + [0x1CE2, 0x1CE8], + [0x1CED, 0x1CED], + [0x1CF4, 0x1CF4], + [0x1CF8, 0x1CF9], + [0x1DC0, 0x1DF9], + [0x1DFB, 0x1DFF], + [0x200B, 0x200F], + [0x202A, 0x202E], + [0x2060, 0x2064], + [0x2066, 0x206F], + [0x20D0, 0x20F0], + [0x2CEF, 0x2CF1], + [0x2D7F, 0x2D7F], + [0x2DE0, 0x2DFF], + [0x302A, 0x302D], + [0x3099, 0x309A], + [0xA66F, 0xA672], + [0xA674, 0xA67D], + [0xA69E, 0xA69F], + [0xA6F0, 0xA6F1], + [0xA802, 0xA802], + [0xA806, 0xA806], + [0xA80B, 0xA80B], + [0xA825, 0xA826], + [0xA8C4, 0xA8C5], + [0xA8E0, 0xA8F1], + [0xA8FF, 0xA8FF], + [0xA926, 0xA92D], + [0xA947, 0xA951], + [0xA980, 0xA982], + [0xA9B3, 0xA9B3], + [0xA9B6, 0xA9B9], + [0xA9BC, 0xA9BD], + [0xA9E5, 0xA9E5], + [0xAA29, 0xAA2E], + [0xAA31, 0xAA32], + [0xAA35, 0xAA36], + [0xAA43, 0xAA43], + [0xAA4C, 0xAA4C], + [0xAA7C, 0xAA7C], + [0xAAB0, 0xAAB0], + [0xAAB2, 0xAAB4], + [0xAAB7, 0xAAB8], + [0xAABE, 0xAABF], + [0xAAC1, 0xAAC1], + [0xAAEC, 0xAAED], + [0xAAF6, 0xAAF6], + [0xABE5, 0xABE5], + [0xABE8, 0xABE8], + [0xABED, 0xABED], + [0xFB1E, 0xFB1E], + [0xFE00, 0xFE0F], + [0xFE20, 0xFE2F], + [0xFEFF, 0xFEFF], + [0xFFF9, 0xFFFB], +]; + +const HIGH_COMBINING = [ + [0x101FD, 0x101FD], + [0x102E0, 0x102E0], + [0x10376, 0x1037A], + [0x10A01, 0x10A03], + [0x10A05, 0x10A06], + [0x10A0C, 0x10A0F], + [0x10A38, 0x10A3A], + [0x10A3F, 0x10A3F], + [0x10AE5, 0x10AE6], + [0x10D24, 0x10D27], + [0x10F46, 0x10F50], + [0x11001, 0x11001], + [0x11038, 0x11046], + [0x1107F, 0x11081], + [0x110B3, 0x110B6], + [0x110B9, 0x110BA], + [0x110BD, 0x110BD], + [0x110CD, 0x110CD], + [0x11100, 0x11102], + [0x11127, 0x1112B], + [0x1112D, 0x11134], + [0x11173, 0x11173], + [0x11180, 0x11181], + [0x111B6, 0x111BE], + [0x111C9, 0x111CC], + [0x1122F, 0x11231], + [0x11234, 0x11234], + [0x11236, 0x11237], + [0x1123E, 0x1123E], + [0x112DF, 0x112DF], + [0x112E3, 0x112EA], + [0x11300, 0x11301], + [0x1133B, 0x1133C], + [0x11340, 0x11340], + [0x11366, 0x1136C], + [0x11370, 0x11374], + [0x11438, 0x1143F], + [0x11442, 0x11444], + [0x11446, 0x11446], + [0x1145E, 0x1145E], + [0x114B3, 0x114B8], + [0x114BA, 0x114BA], + [0x114BF, 0x114C0], + [0x114C2, 0x114C3], + [0x115B2, 0x115B5], + [0x115BC, 0x115BD], + [0x115BF, 0x115C0], + [0x115DC, 0x115DD], + [0x11633, 0x1163A], + [0x1163D, 0x1163D], + [0x1163F, 0x11640], + [0x116AB, 0x116AB], + [0x116AD, 0x116AD], + [0x116B0, 0x116B5], + [0x116B7, 0x116B7], + [0x1171D, 0x1171F], + [0x11722, 0x11725], + [0x11727, 0x1172B], + [0x1182F, 0x11837], + [0x11839, 0x1183A], + [0x119D4, 0x119D7], + [0x119DA, 0x119DB], + [0x119E0, 0x119E0], + [0x11A01, 0x11A0A], + [0x11A33, 0x11A38], + [0x11A3B, 0x11A3E], + [0x11A47, 0x11A47], + [0x11A51, 0x11A56], + [0x11A59, 0x11A5B], + [0x11A8A, 0x11A96], + [0x11A98, 0x11A99], + [0x11C30, 0x11C36], + [0x11C38, 0x11C3D], + [0x11C3F, 0x11C3F], + [0x11C92, 0x11CA7], + [0x11CAA, 0x11CB0], + [0x11CB2, 0x11CB3], + [0x11CB5, 0x11CB6], + [0x11D31, 0x11D36], + [0x11D3A, 0x11D3A], + [0x11D3C, 0x11D3D], + [0x11D3F, 0x11D45], + [0x11D47, 0x11D47], + [0x11D90, 0x11D91], + [0x11D95, 0x11D95], + [0x11D97, 0x11D97], + [0x11EF3, 0x11EF4], + [0x13430, 0x13438], + [0x16AF0, 0x16AF4], + [0x16B30, 0x16B36], + [0x16F4F, 0x16F4F], + [0x16F8F, 0x16F92], + [0x1BC9D, 0x1BC9E], + [0x1BCA0, 0x1BCA3], + [0x1D167, 0x1D169], + [0x1D173, 0x1D182], + [0x1D185, 0x1D18B], + [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], + [0x1DA00, 0x1DA36], + [0x1DA3B, 0x1DA6C], + [0x1DA75, 0x1DA75], + [0x1DA84, 0x1DA84], + [0x1DA9B, 0x1DA9F], + [0x1DAA1, 0x1DAAF], + [0x1E000, 0x1E006], + [0x1E008, 0x1E018], + [0x1E01B, 0x1E021], + [0x1E023, 0x1E024], + [0x1E026, 0x1E02A], + [0x1E130, 0x1E136], + [0x1E2EC, 0x1E2EF], + [0x1E8D0, 0x1E8D6], + [0x1E944, 0x1E94A], + [0xE0001, 0xE0001], + [0xE0020, 0xE007F], + [0xE0100, 0xE01EF], +]; + +const BMP_WIDE = [ + [0x1100, 0x115F], + [0x231A, 0x231B], + [0x2329, 0x232A], + [0x23E9, 0x23EC], + [0x23F0, 0x23F0], + [0x23F3, 0x23F3], + [0x25FD, 0x25FE], + [0x2614, 0x2615], + [0x2648, 0x2653], + [0x267F, 0x267F], + [0x2693, 0x2693], + [0x26A1, 0x26A1], + [0x26AA, 0x26AB], + [0x26BD, 0x26BE], + [0x26C4, 0x26C5], + [0x26CE, 0x26CE], + [0x26D4, 0x26D4], + [0x26EA, 0x26EA], + [0x26F2, 0x26F3], + [0x26F5, 0x26F5], + [0x26FA, 0x26FA], + [0x26FD, 0x26FD], + [0x2705, 0x2705], + [0x270A, 0x270B], + [0x2728, 0x2728], + [0x274C, 0x274C], + [0x274E, 0x274E], + [0x2753, 0x2755], + [0x2757, 0x2757], + [0x2795, 0x2797], + [0x27B0, 0x27B0], + [0x27BF, 0x27BF], + [0x2B1B, 0x2B1C], + [0x2B50, 0x2B50], + [0x2B55, 0x2B55], + [0x2E80, 0x2E99], + [0x2E9B, 0x2EF3], + [0x2F00, 0x2FD5], + [0x2FF0, 0x2FFB], + [0x3000, 0x3029], + [0x302E, 0x303E], + [0x3041, 0x3096], + [0x309B, 0x30FF], + [0x3105, 0x312F], + [0x3131, 0x318E], + [0x3190, 0x31BA], + [0x31C0, 0x31E3], + [0x31F0, 0x321E], + [0x3220, 0x3247], + [0x3250, 0x4DBF], + [0x4E00, 0xA48C], + [0xA490, 0xA4C6], + [0xA960, 0xA97C], + [0xAC00, 0xD7A3], + [0xF900, 0xFAFF], + [0xFE10, 0xFE19], + [0xFE30, 0xFE52], + [0xFE54, 0xFE66], + [0xFE68, 0xFE6B], + [0xFF01, 0xFF60], + [0xFFE0, 0xFFE6], +]; + +const HIGH_WIDE = [ + [0x16FE0, 0x16FE3], + [0x17000, 0x187F7], + [0x18800, 0x18AF2], + [0x1B000, 0x1B11E], + [0x1B150, 0x1B152], + [0x1B164, 0x1B167], + [0x1B170, 0x1B2FB], + [0x1F004, 0x1F004], + [0x1F0CF, 0x1F0CF], + [0x1F18E, 0x1F18E], + [0x1F191, 0x1F19A], + [0x1F200, 0x1F202], + [0x1F210, 0x1F23B], + [0x1F240, 0x1F248], + [0x1F250, 0x1F251], + [0x1F260, 0x1F265], + [0x1F300, 0x1F320], + [0x1F32D, 0x1F335], + [0x1F337, 0x1F37C], + [0x1F37E, 0x1F393], + [0x1F3A0, 0x1F3CA], + [0x1F3CF, 0x1F3D3], + [0x1F3E0, 0x1F3F0], + [0x1F3F4, 0x1F3F4], + [0x1F3F8, 0x1F43E], + [0x1F440, 0x1F440], + [0x1F442, 0x1F4FC], + [0x1F4FF, 0x1F53D], + [0x1F54B, 0x1F54E], + [0x1F550, 0x1F567], + [0x1F57A, 0x1F57A], + [0x1F595, 0x1F596], + [0x1F5A4, 0x1F5A4], + [0x1F5FB, 0x1F64F], + [0x1F680, 0x1F6C5], + [0x1F6CC, 0x1F6CC], + [0x1F6D0, 0x1F6D2], + [0x1F6D5, 0x1F6D5], + [0x1F6EB, 0x1F6EC], + [0x1F6F4, 0x1F6FA], + [0x1F7E0, 0x1F7EB], + [0x1F90D, 0x1F971], + [0x1F973, 0x1F976], + [0x1F97A, 0x1F9A2], + [0x1F9A5, 0x1F9AA], + [0x1F9AE, 0x1F9CA], + [0x1F9CD, 0x1F9FF], + [0x1FA70, 0x1FA73], + [0x1FA78, 0x1FA7A], + [0x1FA80, 0x1FA82], + [0x1FA90, 0x1FA95], + [0x20000, 0x2FFFD], + [0x30000, 0x3FFFD], +]; + +final table = buildTable(); + +Uint8List buildTable() { + final table = Uint8List(65536); + table.fillRange(0, table.length, 1); + table[0] = 0; + table.fillRange(1, 32, 0); + table.fillRange(0x7f, 0xa0, 0); + for (var r = 0; r < BMP_COMBINING.length; ++r) { + table.fillRange(BMP_COMBINING[r][0], BMP_COMBINING[r][1] + 1, 0); + } + for (var r = 0; r < BMP_WIDE.length; ++r) { + table.fillRange(BMP_WIDE[r][0], BMP_WIDE[r][1] + 1, 2); + } + return table; +} + +bool bisearch(int ucs, List> data) { + var min = 0; + var max = data.length - 1; + int mid; + if (ucs < data[0][0] || ucs > data[max][1]) { + return false; + } + while (max >= min) { + mid = (min + max) >> 1; + if (ucs > data[mid][1]) { + min = mid + 1; + } else if (ucs < data[mid][0]) { + max = mid - 1; + } else { + return true; + } + } + return false; +} + +class UnicodeV11 { + final version = '11'; + + int wcwidth(int codePoint) { + if (codePoint < 32) return 0; + if (codePoint < 127) return 1; + if (codePoint < 65536) return table[codePoint]; + if (bisearch(codePoint, HIGH_COMBINING)) return 0; + if (bisearch(codePoint, HIGH_WIDE)) return 2; + return 1; + } +} + +final unicodeV11 = UnicodeV11(); diff --git a/lib/src/terminal/terminal.dart b/lib/src/terminal/terminal.dart new file mode 100644 index 00000000..4ce9c56e --- /dev/null +++ b/lib/src/terminal/terminal.dart @@ -0,0 +1,36 @@ +// clide terminal — based on xterm.dart v4.0.0 by xuty (MIT). +// See LICENSE in this directory for the original copyright notice. +library; + +export 'src/core/buffer/buffer.dart'; +export 'src/core/buffer/cell_flags.dart'; +export 'src/core/buffer/cell_offset.dart'; +export 'src/core/buffer/line.dart'; +export 'src/core/buffer/range.dart'; +export 'src/core/buffer/range_block.dart'; +export 'src/core/buffer/range_line.dart'; +export 'src/core/buffer/segment.dart'; +export 'src/core/cell.dart'; +export 'src/core/color.dart'; +export 'src/core/cursor.dart'; +export 'src/core/escape/handler.dart'; +export 'src/core/escape/parser.dart'; +export 'src/core/input/handler.dart'; +export 'src/core/input/keys.dart'; +export 'src/core/mouse/button.dart'; +export 'src/core/mouse/button_state.dart'; +export 'src/core/mouse/handler.dart'; +export 'src/core/mouse/mode.dart'; +export 'src/core/platform.dart'; +export 'src/core/state.dart'; +export 'src/terminal.dart'; +export 'src/terminal_view.dart'; +export 'src/ui/controller.dart'; +export 'src/ui/cursor_type.dart'; +export 'src/ui/keyboard_visibility.dart'; +export 'src/ui/pointer_input.dart'; +export 'src/ui/selection_mode.dart'; +export 'src/ui/shortcut/shortcuts.dart'; +export 'src/ui/terminal_text_style.dart'; +export 'src/ui/terminal_theme.dart'; +export 'src/ui/themes.dart'; diff --git a/lib/widgets/src/clide_pty_view.dart b/lib/widgets/src/clide_pty_view.dart index 8097bef1..22112d14 100644 --- a/lib/widgets/src/clide_pty_view.dart +++ b/lib/widgets/src/clide_pty_view.dart @@ -2,7 +2,7 @@ import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/tokens.dart'; import 'package:clide/widgets/src/typography.dart'; import 'package:flutter/widgets.dart'; -import 'package:xterm/xterm.dart'; +import 'package:clide/src/terminal/terminal.dart'; /// Theme-aware terminal view. Wraps xterm.dart's [TerminalView] with /// clide token bindings, JetBrainsMono as the face, and a Semantics @@ -54,7 +54,7 @@ class ClidePtyView extends StatelessWidget { fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback, ), - padding: EdgeInsets.zero, + padding: const EdgeInsets.all(2), backgroundOpacity: 1, cursorType: TerminalCursorType.block, ), diff --git a/pubspec.lock b/pubspec.lock index 2e4efbe9..d922f451 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -410,14 +410,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" shelf: dependency: transitive description: @@ -623,14 +615,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" - xterm: - dependency: "direct main" - description: - name: xterm - sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" - url: "https://pub.dev" - source: hosted - version: "4.0.0" yaml: dependency: "direct main" description: @@ -639,14 +623,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" - zmodem: - dependency: transitive - description: - name: zmodem - sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" - url: "https://pub.dev" - source: hosted - version: "0.0.6" sdks: dart: ">=3.9.0 <4.0.0" flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index bd4fbb74..4b843ac9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,6 @@ dependencies: yaml: 3.1.3 - xterm: 4.0.0 - ffi: 2.1.3 jovial_svg: 1.1.26 @@ -74,6 +72,7 @@ flutter: - assets/logo/logo-192.png - assets/logo/appicon.svg - assets/logo/appicon-256.png + - assets/clide.tmux.conf - assets/grammars/ - assets/queries/ @@ -96,8 +95,3 @@ flutter: - asset: assets/fonts/jetbrains_mono/JetBrainsMono-Regular.ttf - asset: assets/fonts/jetbrains_mono/JetBrainsMono-Italic.ttf style: italic - - asset: assets/fonts/jetbrains_mono/JetBrainsMono-Bold.ttf - weight: 700 - - asset: assets/fonts/jetbrains_mono/JetBrainsMono-BoldItalic.ttf - weight: 700 - style: italic