Compare commits
22
Commits
ad807fb0df
...
1fb4786f18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb4786f18 | ||
|
|
2d26af5934 | ||
|
|
26cc1b3154 | ||
|
|
0f2f5180ee | ||
|
|
257a333904 | ||
|
|
94abebf904 | ||
|
|
f3e164b834 | ||
|
|
3b352fe654 | ||
|
|
9331055b44 | ||
|
|
d47d01495c | ||
|
|
c233b7607d | ||
|
|
c744c8ce4b | ||
|
|
1d75e8e1cd | ||
|
|
eb32396bd4 | ||
|
|
f204e91f29 | ||
|
|
1151a0ce80 | ||
|
|
a67a768592 | ||
|
|
699bd40423 | ||
|
|
bfa128f131 | ||
|
|
f0bb2ffcce | ||
|
|
301322a6b0 | ||
|
|
53a61d9269 |
@@ -29,7 +29,8 @@
|
||||
"Bash(flutter *)",
|
||||
"Bash(make *)",
|
||||
"Bash(pql)",
|
||||
"Bash(pql *)"
|
||||
"Bash(pql *)",
|
||||
"Bash(awk *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf /*)",
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: ci/test.sh
|
||||
- run: ci/test_a11y.sh
|
||||
- run: ci/test_coverage.sh
|
||||
- run: ci/coverage_gate.sh
|
||||
|
||||
integration:
|
||||
name: integration_test (xvfb)
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# pql: source .pql/hooks/post-merge (planning-state auto-import on pull)
|
||||
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-merge"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# pql: source .pql/hooks/pre-commit (planning-state auto-export)
|
||||
. "$(git rev-parse --show-toplevel)/.pql/hooks/pre-commit"
|
||||
@@ -36,6 +36,11 @@ tools/ui/.serve.pid
|
||||
# -- dart doc output (generated by `dart doc`, uploaded as CI artefact) -
|
||||
/doc/
|
||||
|
||||
# -- coverage output (regenerated by every `flutter test --coverage`).
|
||||
# Floor lives in pubspec.yaml `coverage_floor:`; nothing under
|
||||
# coverage/ is committed.
|
||||
/coverage/
|
||||
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
*.out
|
||||
|
||||
+285
-113
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Pre-push coverage gate — `make push-check` (and the
|
||||
`.githooks/pre-push` hook that calls it) now runs
|
||||
`ci/coverage_gate.sh`, which fails if total line coverage drops
|
||||
below the `coverage_floor:` value in `pubspec.yaml`. The floor
|
||||
starts at 34% (today's measured floor) and only ratchets up; the
|
||||
end target is 95% (D-66). `ci/test.sh` now writes
|
||||
`coverage/lcov.info` as a side effect of the unit/widget/golden
|
||||
run so the gate adds no extra test invocation.
|
||||
- Staged `dart doc` CI job — generates and uploads an HTML API
|
||||
reference for the public `lib/` surface. The step wraps
|
||||
`dart doc --validate-links` and grep-fails the build on any warning,
|
||||
@@ -37,8 +45,23 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
mounted via IndexedStack so switching tabs preserves their state
|
||||
(PTY connections, scroll position, etc.).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
|
||||
anchors due to concurrent list modification during iteration —
|
||||
surfaced by unit tests added under T-91. Anchors disposed inside
|
||||
the loop were unhooking themselves from the same list the loop was
|
||||
iterating, causing later anchors to be silently skipped (no
|
||||
reposition, no dispose) and leaving the buffer in an inconsistent
|
||||
state. Iteration now snapshots the list first.
|
||||
|
||||
### Changed
|
||||
|
||||
- Terminal panes now render bold attributes with a real bold weight —
|
||||
bundled JetBrainsMono Bold + BoldItalic are registered with the
|
||||
`JetBrainsMono` family at `weight: 700`. The painter's bold
|
||||
suppression workaround (added when only Regular + Italic were wired
|
||||
and Flutter's synthetic bold drifted advance widths) is gone.
|
||||
- Claude pane uses `MultitabPane` for primary + secondaries — drops
|
||||
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
|
||||
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
|
||||
|
||||
@@ -28,6 +28,7 @@ These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. See [`D-4`](decisions/architecture.md#d-4-ignore-file-strategy).
|
||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions as `Q-NNN`. Rejected alternatives as `R-NNN`. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`decisions/README.md`](decisions/README.md).
|
||||
- **No pre-existing excuse.** Solo-dev repo — every failure encountered is yours to fix, regardless of who introduced it. If `make test` is red, a golden is broken, or `flutter analyze` shows a warning when you start working, the order is: **fix it first, then your work**. If you genuinely can't fix it in scope (separate ticket, large sweep, missing context), stop and surface it before continuing — don't push on top of broken state. "It was already broken" is not a reason to add more on top.
|
||||
|
||||
## Repo layout
|
||||
|
||||
|
||||
@@ -102,9 +102,9 @@ test-e2e: ## End-to-end Playwright smoke.
|
||||
.PHONY: test-all
|
||||
test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequentially.
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## flutter test --coverage + lcov summary.
|
||||
ci/test_coverage.sh
|
||||
.PHONY: coverage-gate
|
||||
coverage-gate: ## Coverage gate — fails if total line % < pubspec.yaml `coverage_floor:` (D-66). Assumes `make test` ran first.
|
||||
ci/coverage_gate.sh
|
||||
|
||||
.PHONY: smoke-bundle
|
||||
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
||||
@@ -276,7 +276,7 @@ decisions-validate: ## Parser dry-run over decisions/*.md.
|
||||
pql decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
push-check: decisions-validate test-core test test-a11y ## Pre-push gate.
|
||||
push-check: decisions-validate test-core test test-a11y coverage-gate ## Pre-push gate.
|
||||
|
||||
.PHONY: hooks
|
||||
hooks: ## Install the repo's git hooks.
|
||||
|
||||
@@ -53,7 +53,7 @@ dependencies:
|
||||
purpose: >-
|
||||
Monospace face for terminal panes, diff views, code editors, and
|
||||
any other monospace surface.
|
||||
weights_bundled: [Regular, Italic]
|
||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||
|
||||
- name: Josefin Sans
|
||||
kind: font
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
||||
@@ -25,7 +24,6 @@ import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
|
||||
Future<void> main(List<String> argv) async {
|
||||
if (argv.isEmpty) {
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Coverage gate — fails if total line coverage drops below the
|
||||
# `coverage_floor:` value in pubspec.yaml. Driven by D-66.
|
||||
#
|
||||
# Reads coverage/lcov.info (generated by `flutter test --coverage`,
|
||||
# which `ci/test.sh` runs as part of the fast suite). Parses the
|
||||
# total LF/LH counts and compares the integer percentage against
|
||||
# the floor. The floor only ratchets up — bumping it requires an
|
||||
# explicit edit to pubspec.yaml committed alongside the test
|
||||
# additions that earned the bump.
|
||||
#
|
||||
# Self-contained parser (awk) — does not depend on `lcov` being
|
||||
# installed on the dev machine.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
LCOV=coverage/lcov.info
|
||||
|
||||
if [[ ! -f "$LCOV" ]]; then
|
||||
echo "==> coverage gate: $LCOV missing — run \`make test\` first (it writes lcov)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
floor=$(awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2; exit}' pubspec.yaml)
|
||||
if [[ -z "$floor" ]]; then
|
||||
echo "==> coverage gate: pubspec.yaml is missing coverage_floor: — see D-66" >&2
|
||||
exit 2
|
||||
fi
|
||||
read measured measured_int < <(
|
||||
awk -F: '
|
||||
/^LF:/ { lf += $2 }
|
||||
/^LH:/ { lh += $2 }
|
||||
END {
|
||||
pct = (lh / lf) * 100
|
||||
printf "%.2f %d\n", pct, int(pct)
|
||||
}
|
||||
' "$LCOV"
|
||||
)
|
||||
|
||||
if (( measured_int < floor )); then
|
||||
echo "==> coverage gate FAIL: ${measured}% < floor ${floor}%"
|
||||
echo " Add tests, or — if the drop is intentional — explain in the commit and lower the floor explicitly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( measured_int > floor )); then
|
||||
echo "==> coverage gate OK: ${measured}% (floor ${floor}%) — ${measured_int}% available; consider bumping coverage/floor.txt to ${measured_int}"
|
||||
else
|
||||
echo "==> coverage gate OK: ${measured}% (floor ${floor}%)"
|
||||
fi
|
||||
+2
-2
@@ -14,5 +14,5 @@ dart format --set-exit-if-changed .
|
||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||
dart test --tags forkpty test/pty/session_test.dart
|
||||
|
||||
echo "==> flutter test (unit + widget + golden)"
|
||||
flutter test --exclude-tags forkpty
|
||||
echo "==> flutter test --coverage (unit + widget + golden)"
|
||||
flutter test --coverage --exclude-tags forkpty
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate + summarize lcov coverage. No thresholds yet (see plan's
|
||||
# "Open questions deferred" — we let the suite run for a week of real
|
||||
# commits before setting hard gates that would just need tuning).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> flutter test --coverage"
|
||||
flutter test --coverage
|
||||
|
||||
if command -v lcov >/dev/null 2>&1; then
|
||||
echo "==> lcov summary"
|
||||
lcov --summary coverage/lcov.info
|
||||
fi
|
||||
@@ -60,4 +60,12 @@ Test pyramid, drivers, client-side constraint.
|
||||
- **Cost:** pql / daemon / extension tests stand up real subprocesses and real sockets locally — no mocked network convenience.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-66: Line coverage gate at 95%, ratcheted from current
|
||||
- **Date:** 2026-05-06
|
||||
- **Decision:** The pre-push gate runs `flutter test --coverage --exclude-tags forkpty`, parses `coverage/lcov.info`, and hard-fails if total line coverage drops below a committed floor at `coverage/floor.txt`. The floor starts at the actual current coverage (≈35%, dragged down by `lib/src/terminal/`'s 0.4%) and only ever ratchets up. The end target is 95%; getting there is tracked as a campaign of deliberate floor bumps under one epic ticket. **No carve-outs** — code under `lib/` is owned regardless of file-header attribution, including the terminal emulator port. Branch coverage is not gated (Dart's lcov output models it weakly). Lint suppressions to dodge the gate are never acceptable.
|
||||
- **Rationale:** A flat 95% threshold today blocks every push; an informational coverage report rots into noise. The committed-floor ratchet makes "don't make it worse" the durable rule and turns the journey to 95% into explicit, reviewed bumps rather than a single overnight cliff. Excluding `forkpty`-tagged tests matches `ci/test.sh` (forkpty + flutter test runner are incompatible — see `test/pty/session_test.dart`).
|
||||
- **Cost:** Pre-push wall time grows by `flutter test --coverage` (currently ≈11 s on this tree). Acceptable within D-29's < 90 s budget; reassess if it slips. Floor bumps require an explicit edit to `coverage/floor.txt` in the same commit that adds tests — so contributors can't silently raise it.
|
||||
- **Cross-reference:** [D-29](#d-29-pre-push-gate-fast-layer-only).
|
||||
- **Raised by:** 2026-05-06 — coverage triage during T-73 follow-up.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,9 +129,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||
}
|
||||
|
||||
_sessionName = widget.isPrimary
|
||||
? primarySessionName(repoRoot)
|
||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
|
||||
final tmuxConf = await _ensureTmuxConf();
|
||||
final cols = _terminal.viewWidth;
|
||||
@@ -139,7 +137,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'-L', 'clide',
|
||||
'-L',
|
||||
'clide',
|
||||
if (tmuxConf != null) ...['-f', tmuxConf],
|
||||
'new-session',
|
||||
'-A',
|
||||
@@ -215,14 +214,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
if (_flushTimer == null) {
|
||||
_flushTimer = Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
_flushTimer ??= Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _statusLine = widget.isPrimary
|
||||
? 'session exited — restart clide to retry'
|
||||
: 'session exited');
|
||||
setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited');
|
||||
case 'pane.closed':
|
||||
_paneId = null;
|
||||
}
|
||||
@@ -252,10 +247,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
if (_sessionName != null) {
|
||||
Process.run('tmux', [
|
||||
'-L', 'clide', 'resize-window',
|
||||
'-t', _sessionName!,
|
||||
'-x', '$cols',
|
||||
'-y', '$rows',
|
||||
'-L',
|
||||
'clide',
|
||||
'resize-window',
|
||||
'-t',
|
||||
_sessionName!,
|
||||
'-x',
|
||||
'$cols',
|
||||
'-y',
|
||||
'$rows',
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -277,9 +277,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary
|
||||
? 'claude — primary'
|
||||
: 'claude — secondary ${widget.secondaryIndex}';
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
|
||||
@@ -14,8 +14,7 @@ typedef TmuxRunner = Future<ProcessResult> Function(List<String> args);
|
||||
|
||||
TmuxRunner tmuxRunner = _defaultRunner;
|
||||
|
||||
Future<ProcessResult> _defaultRunner(List<String> args) =>
|
||||
Process.run('tmux', args);
|
||||
Future<ProcessResult> _defaultRunner(List<String> args) => Process.run('tmux', args);
|
||||
|
||||
const _socket = ['-L', 'clide'];
|
||||
|
||||
@@ -30,11 +29,7 @@ Future<void> killSession(String name) async {
|
||||
Future<List<String>> listClideSessions() async {
|
||||
final r = await tmuxRunner([..._socket, 'list-sessions', '-F', '#{session_name}']);
|
||||
if (r.exitCode != 0) return const [];
|
||||
return (r.stdout as String)
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return (r.stdout as String).split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Kill every secondary clide-claude session whose name begins with
|
||||
|
||||
@@ -123,8 +123,9 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_decisions.isEmpty)
|
||||
if (_decisions.isEmpty) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||
}
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:clide/builtin/decisions/src/decision_detail_view.dart';
|
||||
import 'package:clide/builtin/decisions/src/decisions_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class DecisionsExtension extends ClideExtension {
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:clide/builtin/tickets/src/ticket_detail_view.dart';
|
||||
import 'package:clide/builtin/tickets/src/tickets_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class TicketsExtension extends ClideExtension {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class TicketDetail {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
|
||||
class InProcessClient extends DaemonClient {
|
||||
InProcessClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required DaemonDispatcher dispatcher,
|
||||
}) : _dispatcher = dispatcher,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
required super.log,
|
||||
required super.events,
|
||||
required this.dispatcher,
|
||||
}) : super(socketPath: '');
|
||||
|
||||
DaemonDispatcher _dispatcher;
|
||||
|
||||
DaemonDispatcher get dispatcher => _dispatcher;
|
||||
set dispatcher(DaemonDispatcher d) => _dispatcher = d;
|
||||
DaemonDispatcher dispatcher;
|
||||
int _nextReqId = 0;
|
||||
|
||||
@override
|
||||
@@ -31,6 +24,6 @@ class InProcessClient extends DaemonClient {
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextReqId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
return _dispatcher.dispatch(req);
|
||||
return dispatcher.dispatch(req);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,15 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
|
||||
class IsolateClient extends DaemonClient {
|
||||
IsolateClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required super.log,
|
||||
required super.events,
|
||||
required SendPort backendPort,
|
||||
}) : _backendPort = backendPort,
|
||||
_events = events,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
super(socketPath: '');
|
||||
|
||||
final SendPort _backendPort;
|
||||
final DaemonBus _events;
|
||||
|
||||
+3
-4
@@ -27,7 +27,6 @@ import 'dart:io' show Directory, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/ipc/in_process.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
@@ -74,7 +73,7 @@ Future<void> main() async {
|
||||
InProcessClient? ipcClient;
|
||||
DaemonBus? daemonBus;
|
||||
|
||||
DaemonDispatcher _buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) {
|
||||
DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
@@ -102,7 +101,7 @@ Future<void> main() async {
|
||||
: (log, events) {
|
||||
daemonBus = events;
|
||||
final workRoot = FilesService.atCwd(events: _BusEventSink(events)).root;
|
||||
final dispatcher = _buildDispatcher(events, toolchain, workRoot);
|
||||
final dispatcher = buildDispatcher(events, toolchain, workRoot);
|
||||
ipcClient = InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
return ipcClient!;
|
||||
},
|
||||
@@ -110,7 +109,7 @@ Future<void> main() async {
|
||||
? null
|
||||
: (path) async {
|
||||
if (ipcClient == null || daemonBus == null) return;
|
||||
ipcClient!.dispatcher = _buildDispatcher(daemonBus!, toolchain, Directory(path));
|
||||
ipcClient!.dispatcher = buildDispatcher(daemonBus!, toolchain, Directory(path));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ class DaemonServer {
|
||||
// Probe by trying to connect — if a live peer answers, refuse
|
||||
// to start so we don't rip its socket out.
|
||||
try {
|
||||
final probe = await Socket.connect(addr, 0)
|
||||
.timeout(const Duration(milliseconds: 200));
|
||||
final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200));
|
||||
await probe.close();
|
||||
throw StateError('clide daemon already running at $socketPath');
|
||||
} on TimeoutException {
|
||||
|
||||
@@ -10,6 +10,24 @@
|
||||
/// set against the Win32 API (named pipes instead of unix sockets).
|
||||
library;
|
||||
|
||||
// File-wide analyzer exceptions, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. These are the textbook FFI-binding
|
||||
// case where the lints work against the file's purpose:
|
||||
//
|
||||
// * `non_constant_identifier_names` — struct field names map 1:1
|
||||
// to POSIX (`man 2 socketpair`, `recvmsg`, `iovec`, `msghdr`).
|
||||
// Keeping snake_case makes the code greppable against the spec
|
||||
// and the field offsets readable next to the C ABI. Dart FFI
|
||||
// layout depends on declaration order + types, not names, so
|
||||
// this is purely a readability call.
|
||||
// * `library_private_types_in_public_api` — the C / Dart function-
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairDart`, etc.)
|
||||
// are implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them
|
||||
// to public would just add noise to the import surface.
|
||||
//
|
||||
// ignore_for_file: non_constant_identifier_names, library_private_types_in_public_api
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
|
||||
@@ -140,11 +140,9 @@ class NativePty {
|
||||
// Pre-allocate error envelopes the child will write to its stdout
|
||||
// (slave PTY → parent's master fd) before _exit, so the parent's
|
||||
// reader sees a real diagnostic instead of an indistinguishable EOF.
|
||||
final chdirErr = 'clide: chdir failed: $workingDirectory\n'
|
||||
.toNativeUtf8(allocator: malloc);
|
||||
final chdirErr = 'clide: chdir failed: $workingDirectory\n'.toNativeUtf8(allocator: malloc);
|
||||
final chdirErrLen = chdirErr.length;
|
||||
final execveErr = 'clide: exec failed: $executable\n'
|
||||
.toNativeUtf8(allocator: malloc);
|
||||
final execveErr = 'clide: exec failed: $executable\n'.toNativeUtf8(allocator: malloc);
|
||||
final execveErrLen = execveErr.length;
|
||||
|
||||
// Allocate ALL native memory before fork.
|
||||
@@ -218,9 +216,13 @@ class NativePty {
|
||||
ffi.Pointer ws,
|
||||
) {
|
||||
malloc.free(shell);
|
||||
for (var i = 0; i < argc; i++) malloc.free(argv[i]);
|
||||
for (var i = 0; i < argc; i++) {
|
||||
malloc.free(argv[i]);
|
||||
}
|
||||
malloc.free(argv);
|
||||
for (var i = 0; i < envc; i++) malloc.free(envp[i]);
|
||||
for (var i = 0; i < envc; i++) {
|
||||
malloc.free(envp[i]);
|
||||
}
|
||||
malloc.free(envp);
|
||||
malloc.free(wd);
|
||||
calloc.free(fdOut);
|
||||
@@ -300,12 +302,14 @@ class NativePty {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
try {
|
||||
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
buf[i] = bytes[i];
|
||||
}
|
||||
var written = 0;
|
||||
while (written < bytes.length) {
|
||||
final n = _nativeWrite(
|
||||
_fd,
|
||||
buf.elementAt(written).cast(),
|
||||
(buf + written).cast(),
|
||||
bytes.length - written,
|
||||
);
|
||||
if (n < 0) {
|
||||
|
||||
@@ -146,11 +146,7 @@ class PtySession {
|
||||
try {
|
||||
libc.setWinsize(masterFd, cols, rows);
|
||||
|
||||
final stdoutLine = await proc.stdout
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 5));
|
||||
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
|
||||
final pid = _extractPid(stdoutLine);
|
||||
|
||||
final code = await proc.exitCode;
|
||||
@@ -188,7 +184,7 @@ class PtySession {
|
||||
while (written < bytes.length) {
|
||||
final n = libc.write(
|
||||
_masterFd,
|
||||
buf.elementAt(written),
|
||||
buf + written,
|
||||
bytes.length - written,
|
||||
);
|
||||
if (n < 0) {
|
||||
|
||||
@@ -572,9 +572,7 @@ class Buffer {
|
||||
continue;
|
||||
}
|
||||
final line = lines[segment.line];
|
||||
if (!(segment.line == range.begin.y ||
|
||||
segment.line == 0 ||
|
||||
line.isWrapped)) {
|
||||
if (!(segment.line == range.begin.y || segment.line == 0 || line.isWrapped)) {
|
||||
builder.write("\n");
|
||||
}
|
||||
builder.write(line.getText(segment.start, segment.end));
|
||||
|
||||
@@ -48,10 +48,5 @@ class CellOffset {
|
||||
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;
|
||||
bool operator ==(Object other) => identical(this, other) || other is CellOffset && runtimeType == other.runtimeType && x == other.x && y == other.y;
|
||||
}
|
||||
|
||||
@@ -179,8 +179,9 @@ class BufferLine with IndexedItem {
|
||||
}
|
||||
|
||||
// Update anchors, remove anchors that are inside the removed range.
|
||||
for (var i = 0; i < _anchors.length; i++) {
|
||||
final anchor = _anchors[i];
|
||||
// Iterate over a snapshot — anchor.dispose() removes itself from
|
||||
// _anchors, so a live for-loop would skip later entries.
|
||||
for (final anchor in _anchors.toList()) {
|
||||
if (anchor.x >= start) {
|
||||
if (anchor.x < start + count) {
|
||||
anchor.dispose();
|
||||
@@ -218,8 +219,9 @@ class BufferLine with IndexedItem {
|
||||
}
|
||||
|
||||
// Update anchors, move anchors that are after the inserted range.
|
||||
for (var i = 0; i < _anchors.length; i++) {
|
||||
final anchor = _anchors[i];
|
||||
// Iterate over a snapshot — anchor.dispose() removes itself from
|
||||
// _anchors, so a live for-loop would skip later entries.
|
||||
for (final anchor in _anchors.toList()) {
|
||||
if (anchor.x >= start + count) {
|
||||
anchor.reposition(anchor.x + count);
|
||||
|
||||
@@ -352,7 +354,8 @@ class BufferLine with IndexedItem {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final anchor in _anchors) {
|
||||
// Snapshot — anchor.dispose() removes itself from _anchors.
|
||||
for (final anchor in _anchors.toList()) {
|
||||
anchor.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ class BufferRangeLine extends BufferRange {
|
||||
@override
|
||||
bool contains(CellOffset position) {
|
||||
final self = normalized;
|
||||
return self.begin.isBeforeOrSame(position) &&
|
||||
self.end.isAfterOrSame(position);
|
||||
return self.begin.isBeforeOrSame(position) && self.end.isAfterOrSame(position);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -18,8 +18,7 @@ class BufferSegment {
|
||||
/// 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);
|
||||
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) {
|
||||
@@ -45,16 +44,10 @@ class BufferSegment {
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
range.hashCode ^ line.hashCode ^ start.hashCode ^ end.hashCode;
|
||||
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;
|
||||
other is BufferSegment && runtimeType == other.runtimeType && range == other.range && line == other.line && start == other.start && end == other.end;
|
||||
}
|
||||
|
||||
@@ -175,17 +175,17 @@ class EscapeParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `ESC >` Reset Application Keypad Mode (DECKPNM)
|
||||
/// `ESC =` Set Application Keypad Mode (DECKPAM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/a_esc_x3c_greater_than/
|
||||
/// https://terminalguide.namepad.de/seq/a_esc_x3d_equals/
|
||||
bool _escHandleSetAppKeypadMode() {
|
||||
handler.setAppKeypadMode(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `ESC =` Set Application Keypad Mode (DECKPAM)
|
||||
/// `ESC >` Reset Application Keypad Mode (DECKPNM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/a_esc_x3d_equals/
|
||||
/// https://terminalguide.namepad.de/seq/a_esc_x3c_greater_than/
|
||||
bool _escHandleResetAppKeypadMode() {
|
||||
handler.setAppKeypadMode(false);
|
||||
return true;
|
||||
@@ -416,8 +416,6 @@ class EscapeParser {
|
||||
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) {
|
||||
@@ -967,9 +965,7 @@ class EscapeParser {
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled
|
||||
? handler.setMouseMode(MouseMode.clickOnly)
|
||||
: handler.setMouseMode(MouseMode.none);
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
@@ -984,38 +980,23 @@ class EscapeParser {
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
case 10061000:
|
||||
return enabled
|
||||
? handler.setMouseMode(MouseMode.upDownScroll)
|
||||
: handler.setMouseMode(MouseMode.none);
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled
|
||||
? handler.setMouseMode(MouseMode.upDownScroll)
|
||||
: handler.setMouseMode(MouseMode.none);
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled
|
||||
? handler.setMouseMode(MouseMode.upDownScrollDrag)
|
||||
: handler.setMouseMode(MouseMode.none);
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled
|
||||
? handler.setMouseMode(MouseMode.upDownScrollMove)
|
||||
: handler.setMouseMode(MouseMode.none);
|
||||
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);
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled
|
||||
? handler.setMouseReportMode(MouseReportMode.sgr)
|
||||
: handler.setMouseReportMode(MouseReportMode.normal);
|
||||
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);
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
|
||||
@@ -173,8 +173,7 @@ class CtrlInputHandler implements TerminalInputHandler {
|
||||
|
||||
final key = event.key;
|
||||
|
||||
if (key.index >= TerminalKey.keyA.index &&
|
||||
key.index <= TerminalKey.keyZ.index) {
|
||||
if (key.index >= TerminalKey.keyA.index && key.index <= TerminalKey.keyZ.index) {
|
||||
final input = key.index - TerminalKey.keyA.index + 1;
|
||||
return String.fromCharCode(input);
|
||||
}
|
||||
@@ -200,8 +199,7 @@ class AltInputHandler implements TerminalInputHandler {
|
||||
|
||||
final key = event.key;
|
||||
|
||||
if (key.index >= TerminalKey.keyA.index &&
|
||||
key.index <= TerminalKey.keyZ.index) {
|
||||
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);
|
||||
|
||||
@@ -68,8 +68,7 @@ class Keytab {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.appCursorKeys != null &&
|
||||
record.appCursorKeys != appCursorKeys) {
|
||||
if (record.appCursorKeys != null && record.appCursorKeys != appCursorKeys) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// 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
|
||||
#
|
||||
@@ -207,9 +204,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -55,9 +55,8 @@ class KeytabParser {
|
||||
}
|
||||
|
||||
void _parseName(TokensReader reader) {
|
||||
if (reader.take()!.type != KeytabTokenType.keyboard) {
|
||||
throw ParseError();
|
||||
}
|
||||
// Caller (`addTokens`) has already gated on the keyboard token type.
|
||||
reader.take();
|
||||
|
||||
final name = reader.take()!;
|
||||
if (name.type != KeytabTokenType.input) {
|
||||
@@ -68,9 +67,8 @@ class KeytabParser {
|
||||
}
|
||||
|
||||
void _parseKeyDefine(TokensReader reader) {
|
||||
if (reader.take()!.type != KeytabTokenType.keyDefine) {
|
||||
throw ParseError();
|
||||
}
|
||||
// Caller has already gated on the keyDefine token type.
|
||||
reader.take();
|
||||
|
||||
final keyName = reader.take()!;
|
||||
|
||||
|
||||
@@ -114,13 +114,11 @@ bool _isKeyDefine(String line) {
|
||||
}
|
||||
|
||||
Iterable<KeytabToken> _parseKeyboardNameDefine(String line) sync* {
|
||||
// Caller has already gated on `_isKeyboardNameDefine`, so the leading
|
||||
// word is guaranteed to be 'keyboard'. Just consume it.
|
||||
final reader = LineReader(line.trim());
|
||||
|
||||
if (reader.readString() == 'keyboard') {
|
||||
yield KeytabToken(KeytabTokenType.keyboard, 'keyboard');
|
||||
} else {
|
||||
throw TokenizeError();
|
||||
}
|
||||
reader.readString();
|
||||
yield KeytabToken(KeytabTokenType.keyboard, 'keyboard');
|
||||
|
||||
reader.skipWhitespace();
|
||||
|
||||
@@ -128,13 +126,10 @@ Iterable<KeytabToken> _parseKeyboardNameDefine(String line) sync* {
|
||||
}
|
||||
|
||||
Iterable<KeytabToken> _parseKeyDefine(String line) sync* {
|
||||
// Caller has already gated on `_isKeyDefine`.
|
||||
final reader = LineReader(line.trim());
|
||||
|
||||
if (reader.readString() == 'key') {
|
||||
yield KeytabToken(KeytabTokenType.keyDefine, 'key');
|
||||
} else {
|
||||
throw TokenizeError();
|
||||
}
|
||||
reader.readString();
|
||||
yield KeytabToken(KeytabTokenType.keyDefine, 'key');
|
||||
|
||||
reader.skipWhitespace();
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@ class ClickMouseHandler implements TerminalMouseHandler {
|
||||
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)) {
|
||||
if (event.buttonState == TerminalMouseButtonState.down && (event.button.id < 3)) {
|
||||
return MouseReporter.report(
|
||||
event.button,
|
||||
event.buttonState,
|
||||
@@ -101,8 +100,7 @@ class UpDownMouseHandler implements TerminalMouseHandler {
|
||||
case MouseMode.upDownScrollDrag:
|
||||
case MouseMode.upDownScrollMove:
|
||||
// Up events are never reported for mouse wheel buttons.
|
||||
if (event.button.isWheel &&
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
if (event.button.isWheel && event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return MouseReporter.report(
|
||||
|
||||
@@ -27,14 +27,9 @@ abstract class MouseReporter {
|
||||
// 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);
|
||||
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;
|
||||
@@ -42,8 +37,7 @@ abstract class MouseReporter {
|
||||
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);
|
||||
final buttonID = 32 + (state == TerminalMouseButtonState.up ? 3 : button.id);
|
||||
return "\x1b[$buttonID;$x;${y}M";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,7 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
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;
|
||||
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
|
||||
|
||||
@@ -8,8 +8,6 @@ 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';
|
||||
@@ -167,8 +165,7 @@ class TerminalViewState extends State<TerminalView> {
|
||||
|
||||
late ScrollController _scrollController;
|
||||
|
||||
RenderTerminal get renderTerminal =>
|
||||
_viewportKey.currentContext!.findRenderObject() as RenderTerminal;
|
||||
RenderTerminal get renderTerminal => _viewportKey.currentContext!.findRenderObject() as RenderTerminal;
|
||||
|
||||
void _onPointerSignal(PointerSignalEvent event) {
|
||||
if (event is! PointerScrollEvent) return;
|
||||
@@ -281,12 +278,12 @@ class TerminalViewState extends State<TerminalView> {
|
||||
} 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: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,10 +303,8 @@ class TerminalViewState extends State<TerminalView> {
|
||||
terminalController: _controller,
|
||||
onTapUp: _onTapUp,
|
||||
onTapDown: _onTapDown,
|
||||
onSecondaryTapDown:
|
||||
widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null,
|
||||
onSecondaryTapUp:
|
||||
widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null,
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null,
|
||||
onSecondaryTapUp: widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null,
|
||||
readOnly: widget.readOnly,
|
||||
child: child,
|
||||
);
|
||||
@@ -320,7 +315,7 @@ class TerminalViewState extends State<TerminalView> {
|
||||
);
|
||||
|
||||
child = Container(
|
||||
color: widget.theme.background.withOpacity(widget.backgroundOpacity),
|
||||
color: widget.theme.background.withValues(alpha: widget.backgroundOpacity),
|
||||
padding: widget.padding,
|
||||
child: child,
|
||||
);
|
||||
@@ -344,8 +339,7 @@ class TerminalViewState extends State<TerminalView> {
|
||||
}
|
||||
|
||||
Rect get globalCursorRect {
|
||||
return renderTerminal.localToGlobal(renderTerminal.cursorOffset) &
|
||||
renderTerminal.cellSize;
|
||||
return renderTerminal.localToGlobal(renderTerminal.cursorOffset) & renderTerminal.cellSize;
|
||||
}
|
||||
|
||||
void _onTapUp(TapUpDetails details) {
|
||||
@@ -353,7 +347,7 @@ class TerminalViewState extends State<TerminalView> {
|
||||
widget.onTapUp?.call(details, offset);
|
||||
}
|
||||
|
||||
void _onTapDown(_) {
|
||||
void _onTapDown(TapDownDetails _) {
|
||||
if (_controller.selection != null) {
|
||||
_controller.clearSelection();
|
||||
} else {
|
||||
|
||||
@@ -117,9 +117,7 @@ class TerminalController with ChangeNotifier {
|
||||
@internal
|
||||
bool shouldSendPointerInput(PointerInput pointerInput) {
|
||||
// Always return false if pointer input is suspended.
|
||||
return _suspendPointerInputs
|
||||
? false
|
||||
: _pointerInputs.inputs.contains(pointerInput);
|
||||
return _suspendPointerInputs ? false : _pointerInputs.inputs.contains(pointerInput);
|
||||
}
|
||||
|
||||
/// Creates a new highlight on the terminal from [p1] to [p2] with the given
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextEdit extends StatefulWidget {
|
||||
CustomTextEdit({
|
||||
const CustomTextEdit({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onInsert,
|
||||
@@ -234,8 +234,7 @@ class CustomTextEditState extends State<CustomTextEdit> with TextInputClient {
|
||||
}
|
||||
|
||||
// Reset editing state if composing is done
|
||||
if (_currentEditingState.composing.isCollapsed &&
|
||||
_currentEditingState.text != _initEditingState.text) {
|
||||
if (_currentEditingState.composing.isCollapsed && _currentEditingState.text != _initEditingState.text) {
|
||||
_connection!.setEditingState(_initEditingState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ class TerminalGestureDetector extends StatefulWidget {
|
||||
final GestureDragUpdateCallback? onDragUpdate;
|
||||
|
||||
@override
|
||||
State<TerminalGestureDetector> createState() =>
|
||||
_TerminalGestureDetectorState();
|
||||
State<TerminalGestureDetector> createState() => _TerminalGestureDetectorState();
|
||||
}
|
||||
|
||||
class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
|
||||
@@ -71,8 +70,7 @@ class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
widget.onTapDown?.call(details);
|
||||
|
||||
if (_doubleTapTimer != null &&
|
||||
_isWithinDoubleTapTolerance(details.globalPosition)) {
|
||||
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);
|
||||
@@ -110,8 +108,7 @@ class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
|
||||
Widget build(BuildContext context) {
|
||||
final gestures = <Type, GestureRecognizerFactory>{};
|
||||
|
||||
gestures[TapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(TapGestureRecognizer instance) {
|
||||
instance
|
||||
@@ -124,8 +121,7 @@ class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
|
||||
},
|
||||
);
|
||||
|
||||
gestures[LongPressGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
debugOwner: this,
|
||||
supportedDevices: {
|
||||
@@ -141,8 +137,7 @@ class _TerminalGestureDetectorState extends State<TerminalGestureDetector> {
|
||||
},
|
||||
);
|
||||
|
||||
gestures[PanGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||
gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||
() => PanGestureRecognizer(
|
||||
debugOwner: this,
|
||||
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.mouse},
|
||||
|
||||
@@ -64,7 +64,6 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TerminalGestureDetector(
|
||||
child: widget.child,
|
||||
onTapUp: widget.onTapUp,
|
||||
onSingleTapUp: onSingleTapUp,
|
||||
onTapDown: onTapDown,
|
||||
@@ -78,12 +77,11 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
|
||||
onDragStart: onDragStart,
|
||||
onDragUpdate: onDragUpdate,
|
||||
onDoubleTapDown: onDoubleTapDown,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _shouldSendTapEvent =>
|
||||
!widget.readOnly &&
|
||||
widget.terminalController.shouldSendPointerInput(PointerInput.tap);
|
||||
bool get _shouldSendTapEvent => !widget.readOnly && widget.terminalController.shouldSendPointerInput(PointerInput.tap);
|
||||
|
||||
void _tapDown(
|
||||
GestureTapDownCallback? callback,
|
||||
@@ -179,9 +177,7 @@ class _TerminalGestureHandlerState extends State<TerminalGestureHandler> {
|
||||
void onDragStart(DragStartDetails details) {
|
||||
_lastDragStartDetails = details;
|
||||
|
||||
details.kind == PointerDeviceKind.mouse
|
||||
? renderTerminal.selectCharacters(details.localPosition)
|
||||
: renderTerminal.selectWord(details.localPosition);
|
||||
details.kind == PointerDeviceKind.mouse ? renderTerminal.selectCharacters(details.localPosition) : renderTerminal.selectWord(details.localPosition);
|
||||
}
|
||||
|
||||
void onDragUpdate(DragUpdateDetails details) {
|
||||
|
||||
@@ -20,8 +20,7 @@ class KeyboardVisibilty extends StatefulWidget {
|
||||
KeyboardVisibiltyState createState() => KeyboardVisibiltyState();
|
||||
}
|
||||
|
||||
class KeyboardVisibiltyState extends State<KeyboardVisibilty>
|
||||
with WidgetsBindingObserver {
|
||||
class KeyboardVisibiltyState extends State<KeyboardVisibilty> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
@@ -126,8 +126,7 @@ class TerminalPainter {
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
void paintHighlight(Canvas canvas, Offset offset, int length, Color color) {
|
||||
final endOffset =
|
||||
offset.translate(length * _cellSize.width, _cellSize.height);
|
||||
final endOffset = offset.translate(length * _cellSize.width, _cellSize.height);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
@@ -182,20 +181,15 @@ class TerminalPainter {
|
||||
if (paragraph == null) {
|
||||
final cellFlags = cellData.flags;
|
||||
|
||||
var color = cellFlags & CellFlags.inverse == 0
|
||||
? resolveForegroundColor(cellData.foreground)
|
||||
: resolveBackgroundColor(cellData.background);
|
||||
var color = cellFlags & CellFlags.inverse == 0 ? resolveForegroundColor(cellData.foreground) : resolveBackgroundColor(cellData.background);
|
||||
|
||||
if (cellData.flags & CellFlags.faint != 0) {
|
||||
color = color.withOpacity(0.5);
|
||||
color = color.withValues(alpha: 0.5);
|
||||
}
|
||||
|
||||
// Skip bold rendering — Flutter's synthetic bold (no Bold.ttf
|
||||
// registered) drifts glyph advance widths slightly, breaking
|
||||
// the cell grid. Color is enough to convey emphasis in TUIs.
|
||||
final style = _textStyle.toTextStyle(
|
||||
color: color,
|
||||
bold: false,
|
||||
bold: cellFlags & CellFlags.bold != 0,
|
||||
italic: cellFlags & CellFlags.italic != 0,
|
||||
underline: cellFlags & CellFlags.underline != 0,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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';
|
||||
@@ -8,7 +7,7 @@ import 'package:flutter/widgets.dart';
|
||||
class _LruCache<K, V> {
|
||||
_LruCache(this._maxSize);
|
||||
final int _maxSize;
|
||||
final _map = LinkedHashMap<K, V>();
|
||||
final _map = <K, V>{};
|
||||
|
||||
V? operator [](K key) {
|
||||
final value = _map.remove(key);
|
||||
|
||||
@@ -220,8 +220,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
||||
}
|
||||
|
||||
/// Total height of the terminal in pixels. Includes scrollback buffer.
|
||||
double get _terminalHeight =>
|
||||
_terminal.buffer.lines.length * _painter.cellSize.height;
|
||||
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;
|
||||
@@ -423,8 +422,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
||||
);
|
||||
}
|
||||
|
||||
if (_terminal.buffer.absoluteCursorY >= effectFirstLine &&
|
||||
_terminal.buffer.absoluteCursorY <= effectLastLine) {
|
||||
if (_terminal.buffer.absoluteCursorY >= effectFirstLine && _terminal.buffer.absoluteCursorY <= effectLastLine) {
|
||||
if (_isComposingText) {
|
||||
_paintComposingText(canvas, offset + cursorOffset);
|
||||
}
|
||||
@@ -519,9 +517,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
|
||||
for (var highlight in _controller.highlights) {
|
||||
final range = highlight.range?.normalized;
|
||||
|
||||
if (range == null ||
|
||||
range.begin.y > lastLine ||
|
||||
range.end.y < firstLine) {
|
||||
if (range == null || range.begin.y > lastLine || range.end.y < firstLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,10 @@ class TerminalScrollGestureHandler extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<TerminalScrollGestureHandler> createState() =>
|
||||
_TerminalScrollGestureHandlerState();
|
||||
State<TerminalScrollGestureHandler> createState() => _TerminalScrollGestureHandlerState();
|
||||
}
|
||||
|
||||
class _TerminalScrollGestureHandlerState
|
||||
extends State<TerminalScrollGestureHandler> {
|
||||
class _TerminalScrollGestureHandlerState extends State<TerminalScrollGestureHandler> {
|
||||
var isAltBuffer = false;
|
||||
var _lastPointerPosition = Offset.zero;
|
||||
|
||||
|
||||
@@ -18,19 +18,13 @@ Map<ShortcutActivator, Intent> get defaultTerminalShortcuts {
|
||||
}
|
||||
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -37,11 +37,8 @@ class TerminalStyle {
|
||||
return TerminalStyle(
|
||||
fontSize: textStyle.fontSize ?? _kDefaultFontSize,
|
||||
height: textStyle.height ?? _kDefaultHeight,
|
||||
fontFamily: textStyle.fontFamily ??
|
||||
textStyle.fontFamilyFallback?.first ??
|
||||
_kDefaultFontFamily,
|
||||
fontFamilyFallback:
|
||||
textStyle.fontFamilyFallback ?? _kDefaultFontFamilyFallback,
|
||||
fontFamily: textStyle.fontFamily ?? textStyle.fontFamilyFallback?.first ?? _kDefaultFontFamily,
|
||||
fontFamilyFallback: textStyle.fontFamilyFallback ?? _kDefaultFontFamilyFallback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
/// A circular buffer in which elements know their index in the buffer.
|
||||
class IndexAwareCircularBuffer<T extends IndexedItem> {
|
||||
/// Creates a new circular list with the specified [maxLength].
|
||||
IndexAwareCircularBuffer(int maxLength)
|
||||
: _array = List<T?>.filled(maxLength, null);
|
||||
IndexAwareCircularBuffer(int maxLength) : _array = List<T?>.filled(maxLength, null);
|
||||
|
||||
/// The backing array for this list. Length is always equal to [maxLength].
|
||||
late List<T?> _array;
|
||||
@@ -109,7 +108,7 @@ class IndexAwareCircularBuffer<T extends IndexedItem> {
|
||||
|
||||
/// Sets the element at the specified [index] in the list. Throws if the
|
||||
/// index is out of bounds.
|
||||
operator []=(int index, T value) {
|
||||
void operator []=(int index, T value) {
|
||||
RangeError.checkValueInInterval(index, 0, length - 1, 'index');
|
||||
_adoptChild(index, value);
|
||||
}
|
||||
|
||||
+56
-47
@@ -29,16 +29,12 @@ import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
import 'kernel/src/events/bus.dart';
|
||||
import 'kernel/src/events/types.dart';
|
||||
import 'kernel/src/ipc/in_process.dart';
|
||||
import 'kernel/src/log.dart';
|
||||
import 'src/daemon/pane_commands.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/panes/event_sink.dart';
|
||||
import 'src/panes/registry.dart';
|
||||
import 'src/pty/session.dart';
|
||||
import 'kernel/src/toolchain.dart';
|
||||
import 'src/daemon/dispatcher.dart';
|
||||
import 'src/pty/env.dart' show expandedPath;
|
||||
|
||||
@@ -53,14 +49,21 @@ class ClideTestApp extends StatefulWidget {
|
||||
|
||||
class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final List<_TestResult> _results = [];
|
||||
// Wired through the kernel logger so testmode output goes through
|
||||
// the same plumbing as production code. Sink stays default (stderr);
|
||||
// `make run-testmode` pipes 2>&1 so the harness still grep-checks
|
||||
// the structured `[testmode:json]` line.
|
||||
final _logger = Logger();
|
||||
bool _done = false;
|
||||
|
||||
void _say(String message) => _logger.info('testmode', message);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _runTests());
|
||||
Timer(_timeout, () {
|
||||
print('[testmode] timeout reached — exiting');
|
||||
_say('timeout reached — exiting');
|
||||
exit(1);
|
||||
});
|
||||
}
|
||||
@@ -76,12 +79,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final runExtensions = runAll || category == 'extensions';
|
||||
final runTerminal = runAll || category == 'terminal';
|
||||
|
||||
print('[testmode] === ClideTestApp starting ===');
|
||||
print('[testmode] workspace=$workDir');
|
||||
print('[testmode] cwd=${Directory.current.path}');
|
||||
print('[testmode] category=${runAll ? "all" : category}');
|
||||
print('[testmode] expandedPath=$expandedPath');
|
||||
print('[testmode]');
|
||||
_say('=== ClideTestApp starting ===');
|
||||
_say('workspace=$workDir');
|
||||
_say('cwd=${Directory.current.path}');
|
||||
_say('category=${runAll ? "all" : category}');
|
||||
_say('expandedPath=$expandedPath');
|
||||
_say('');
|
||||
|
||||
final tc = Toolchain();
|
||||
tc.applyResolved(Toolchain.resolvePaths(workspaceRoot: workDir));
|
||||
@@ -95,13 +98,19 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final failed = _results.where((r) => !r.ok).length;
|
||||
final failedNames = _results.where((r) => !r.ok).map((r) => r.name).toList();
|
||||
|
||||
print('[testmode] === done ($passed passed, $failed failed, ${_results.length} total) ===');
|
||||
print('[testmode:json] ${jsonEncode({
|
||||
'passed': passed,
|
||||
'failed': failed,
|
||||
'total': _results.length,
|
||||
'failures': failedNames,
|
||||
})}');
|
||||
_say('=== done ($passed passed, $failed failed, ${_results.length} total) ===');
|
||||
// Emitted under a distinct source so the harness's grep
|
||||
// (`make run-testmode` checks for `"failed":0`) keeps working
|
||||
// without depending on the human-readable lines above.
|
||||
_logger.info(
|
||||
'testmode:json',
|
||||
jsonEncode({
|
||||
'passed': passed,
|
||||
'failed': failed,
|
||||
'total': _results.length,
|
||||
'failures': failedNames,
|
||||
}),
|
||||
);
|
||||
|
||||
setState(() => _done = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
@@ -111,47 +120,47 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
// -- toolchain category ---------------------------------------------------
|
||||
|
||||
Future<void> _runToolchainTests(Toolchain tc, String workDir) async {
|
||||
print('[testmode] --- toolchain ---');
|
||||
_say('--- toolchain ---');
|
||||
_log('toolchain.git', tc.git);
|
||||
_log('toolchain.pql', tc.pql);
|
||||
_log('toolchain.tmux', tc.tmux);
|
||||
_log('toolchain.ptyc', tc.ptyc);
|
||||
_log('toolchain.shell', tc.shell);
|
||||
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
await _testExists('git', tc.git);
|
||||
await _testExists('pql', tc.pql);
|
||||
await _testExists('tmux', tc.tmux);
|
||||
await _testExists('ptyc', tc.ptyc);
|
||||
await _testExists('shell', tc.shell);
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
await _testExec('git --version', tc.git, ['--version'], workDir);
|
||||
await _testExec('pql --version', tc.pql, ['--version'], workDir);
|
||||
await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
|
||||
await _testExec('ptyc (no args)', tc.ptyc, [], workDir);
|
||||
await _testExec('shell --version', tc.shell, ['--version'], workDir);
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
// Shell passthrough — use the resolved shell, not a hardcoded path
|
||||
await _testExec('shell -c git', tc.shell, ['-c', '${tc.git} --version'], workDir);
|
||||
await _testExec('shell -c pql', tc.shell, ['-c', '${tc.pql} --version'], workDir);
|
||||
await _testExec('shell -c tmux', tc.shell, ['-c', '${tc.tmux} -V'], workDir);
|
||||
await _testExec('shell -c git (bare)', tc.shell, ['-c', 'git --version'], workDir);
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
// git with env (dugite needs GIT_EXEC_PATH)
|
||||
await _testExec('git --version (env)', tc.git, ['--version'], workDir, env: tc.gitEnv);
|
||||
await _testExec('git status (env)', tc.git, ['status', '--porcelain'], workDir, env: tc.gitEnv);
|
||||
await _testExec('git rev-parse (env)', tc.git, ['rev-parse', '--show-toplevel'], workDir, env: tc.gitEnv);
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
_log('gitEnv', '${tc.gitEnv}');
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
|
||||
// Boot sequence simulation tests
|
||||
print('[testmode] --- boot sequence ---');
|
||||
_say('--- boot sequence ---');
|
||||
|
||||
await _testAsync('compute(resolveToolchainPaths)', () async {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
@@ -193,13 +202,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}';
|
||||
});
|
||||
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
}
|
||||
|
||||
// -- ipc category ---------------------------------------------------------
|
||||
|
||||
Future<void> _runIpcTests(String workDir) async {
|
||||
print('[testmode] --- ipc ---');
|
||||
_say('--- ipc ---');
|
||||
final dispatcher = DaemonDispatcher();
|
||||
|
||||
// ping round-trip
|
||||
@@ -236,13 +245,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final isReq = decoded is IpcRequest && decoded.cmd == 'ping' && decoded.id == 'test-ping-1';
|
||||
_addResult('ipc encode/decode', isReq, isReq ? 'round-trip ok' : 'mismatch');
|
||||
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
}
|
||||
|
||||
// -- extensions category --------------------------------------------------
|
||||
|
||||
Future<void> _runExtensionTests(String workDir, Toolchain tc) async {
|
||||
print('[testmode] --- extensions ---');
|
||||
_say('--- extensions ---');
|
||||
|
||||
// Theme loading
|
||||
try {
|
||||
@@ -319,13 +328,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
_addResult('ext:boot', false, '$e');
|
||||
}
|
||||
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
}
|
||||
|
||||
// -- terminal category ----------------------------------------------------
|
||||
|
||||
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
||||
print('[testmode] --- terminal ---');
|
||||
_say('--- terminal ---');
|
||||
|
||||
// Test PTY via InProcessClient — same path as the real app.
|
||||
await _testAsync('pane.spawn via IPC', () async {
|
||||
@@ -343,7 +352,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
'argv': [tc.shell],
|
||||
'kind': 'terminal',
|
||||
});
|
||||
print('[testmode] spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||
_say(' spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||
if (!spawnResp.ok) {
|
||||
return 'spawn failed: ${spawnResp.error?.message}';
|
||||
}
|
||||
@@ -360,9 +369,9 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
print('[testmode] events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}');
|
||||
_say(' events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}');
|
||||
if (outputParts.isNotEmpty) {
|
||||
print('[testmode] first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}');
|
||||
_say(' first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}');
|
||||
}
|
||||
await sub.cancel();
|
||||
paneRegistry.shutdown();
|
||||
@@ -393,22 +402,22 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
print('[testmode] session pid=${session.pid} masterFd exists');
|
||||
_say(' session pid=${session.pid} masterFd exists');
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
print('[testmode] got ${chunk.length} bytes');
|
||||
_say(' got ${chunk.length} bytes');
|
||||
},
|
||||
onDone: () {
|
||||
print('[testmode] stream done');
|
||||
_say(' stream done');
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (e) => print('[testmode] stream error: $e'),
|
||||
onError: (e) => _say(' stream error: $e'),
|
||||
);
|
||||
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||
print('[testmode] timeout waiting for output, got ${bytes.length} bytes so far');
|
||||
_say(' timeout waiting for output, got ${bytes.length} bytes so far');
|
||||
});
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
@@ -473,25 +482,25 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
});
|
||||
} // end !Platform.isMacOS
|
||||
|
||||
print('[testmode]');
|
||||
_say('');
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
void _log(String key, String value) {
|
||||
print('[testmode] $key = $value');
|
||||
_say('$key = $value');
|
||||
}
|
||||
|
||||
void _addResult(String name, bool ok, String output) {
|
||||
final r = _TestResult(name: name, detail: '', ok: ok, output: output);
|
||||
print('[testmode] ${ok ? "PASS" : "FAIL"} | $name | $output');
|
||||
_say('${ok ? "PASS" : "FAIL"} | $name | $output');
|
||||
setState(() => _results.add(r));
|
||||
}
|
||||
|
||||
Future<void> _testExists(String name, String path) async {
|
||||
final exists = File(path).existsSync();
|
||||
final r = _TestResult(name: '$name exists', detail: path, ok: exists, output: exists ? 'yes' : 'NO');
|
||||
print('[testmode] exists | $name | path=$path | ${exists ? "yes" : "NO"}');
|
||||
_say('exists | $name | path=$path | ${exists ? "yes" : "NO"}');
|
||||
setState(() => _results.add(r));
|
||||
}
|
||||
|
||||
@@ -514,15 +523,15 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final firstLine = stdout.isNotEmpty ? stdout.split('\n').first : (stderr.isNotEmpty ? stderr.split('\n').first : '(empty)');
|
||||
final ok = r.exitCode == 0 || r.exitCode == 1;
|
||||
final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: ok, output: 'exit=${r.exitCode} $firstLine');
|
||||
print('[testmode] exec | $label | exit=${r.exitCode} | ${ok ? "OK" : "FAIL"} | $firstLine');
|
||||
_say('exec | $label | exit=${r.exitCode} | ${ok ? "OK" : "FAIL"} | $firstLine');
|
||||
setState(() => _results.add(result));
|
||||
} on ProcessException catch (e) {
|
||||
final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: false, output: 'ProcessException: ${e.message}');
|
||||
print('[testmode] exec | $label | EXCEPTION | ${e.message}');
|
||||
_say('exec | $label | EXCEPTION | ${e.message}');
|
||||
setState(() => _results.add(result));
|
||||
} on TimeoutException {
|
||||
final result = _TestResult(name: label, detail: '$bin ${args.join(" ")}', ok: false, output: 'TIMEOUT (5s)');
|
||||
print('[testmode] exec | $label | TIMEOUT');
|
||||
_say('exec | $label | TIMEOUT');
|
||||
setState(() => _results.add(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +144,10 @@ class _TabStrip<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onReorderTo: (draggedId) =>
|
||||
controller.reorder(draggedId, i),
|
||||
onReorderTo: (draggedId) => controller.reorder(draggedId, i),
|
||||
tabHeight: tabHeight,
|
||||
),
|
||||
if (onAddRequested != null)
|
||||
_AddButton(onTap: onAddRequested!, tabHeight: tabHeight),
|
||||
if (onAddRequested != null) _AddButton(onTap: onAddRequested!, tabHeight: tabHeight),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -404,12 +402,9 @@ class _AddButton extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
),
|
||||
child: ClideText('+',
|
||||
fontSize: clideIconStandard,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
child: ClideText('+', fontSize: clideIconStandard, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ publish_to: none
|
||||
version: 2.0.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
|
||||
# Pre-push line-coverage floor. Ratchets up only — see D-66.
|
||||
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
|
||||
coverage_floor: 52
|
||||
|
||||
# Project metadata (was project.yaml, folded in per D-056).
|
||||
# version: above is the single source of truth. The Makefile reads
|
||||
# it for build stamping. Cutting a release bumps version + changelog
|
||||
@@ -95,3 +99,8 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:clide/builtin/ipc_status/ipc_status.dart';
|
||||
import 'package:clide/builtin/ipc_status/src/status_item.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -31,18 +31,15 @@ void main() {
|
||||
});
|
||||
|
||||
test('rejects ../etc/passwd traversal', () {
|
||||
expect(() => resolveUnderRoot(root, '../../../etc/passwd'),
|
||||
throwsA(isA<PathOutsideRoot>()));
|
||||
expect(() => resolveUnderRoot(root, '../../../etc/passwd'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('rejects traversal that lands at filesystem root', () {
|
||||
expect(() => resolveUnderRoot(root, '../'),
|
||||
throwsA(isA<PathOutsideRoot>()));
|
||||
expect(() => resolveUnderRoot(root, '../'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('rejects sibling-directory traversal', () {
|
||||
expect(() => resolveUnderRoot(root, '../sibling/file'),
|
||||
throwsA(isA<PathOutsideRoot>()));
|
||||
expect(() => resolveUnderRoot(root, '../sibling/file'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('allows internal `..` that stays under root', () {
|
||||
@@ -56,8 +53,7 @@ void main() {
|
||||
final twin = Directory('${root.parent.path}/${root.uri.pathSegments.where((s) => s.isNotEmpty).last}_twin');
|
||||
try {
|
||||
twin.createSync();
|
||||
expect(() => resolveUnderRoot(root, '../${twin.uri.pathSegments.where((s) => s.isNotEmpty).last}/file'),
|
||||
throwsA(isA<PathOutsideRoot>()));
|
||||
expect(() => resolveUnderRoot(root, '../${twin.uri.pathSegments.where((s) => s.isNotEmpty).last}/file'), throwsA(isA<PathOutsideRoot>()));
|
||||
} finally {
|
||||
if (twin.existsSync()) twin.deleteSync(recursive: true);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -22,7 +22,10 @@ Widget harness(KernelFixture fixture, Widget child) {
|
||||
data: const MediaQueryData(),
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (_) => child),
|
||||
OverlayEntry(
|
||||
canSizeOverlay: true,
|
||||
builder: (_) => child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
/// Pure-Dart tests for Buffer (lib/src/terminal/src/core/buffer/buffer.dart).
|
||||
///
|
||||
/// Buffer is the orchestrator on top of BufferLine — viewport sizing,
|
||||
/// scroll regions, cursor movement, erase commands, scroll/index/reverse-
|
||||
/// index, line insert/delete, resize + reflow, and word-boundary lookup.
|
||||
library;
|
||||
|
||||
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/range_line.dart';
|
||||
import 'package:clide/src/terminal/src/core/cursor.dart';
|
||||
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
|
||||
import 'package:clide/src/terminal/src/core/state.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _State implements TerminalState {
|
||||
_State({
|
||||
this.viewWidth = 10,
|
||||
this.viewHeight = 5,
|
||||
this.autoWrapMode = true,
|
||||
this.lineFeedMode = false,
|
||||
this.originMode = false,
|
||||
this.reflowEnabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
int viewWidth;
|
||||
@override
|
||||
int viewHeight;
|
||||
@override
|
||||
bool autoWrapMode;
|
||||
@override
|
||||
bool lineFeedMode;
|
||||
@override
|
||||
bool originMode;
|
||||
@override
|
||||
bool reflowEnabled;
|
||||
|
||||
@override
|
||||
final cursor = CursorStyle();
|
||||
|
||||
// Unused-by-Buffer bits — dummy values are fine.
|
||||
@override
|
||||
bool get insertMode => false;
|
||||
@override
|
||||
bool get cursorKeysMode => false;
|
||||
@override
|
||||
bool get reverseDisplayMode => false;
|
||||
@override
|
||||
MouseMode get mouseMode => MouseMode.none;
|
||||
@override
|
||||
MouseReportMode get mouseReportMode => MouseReportMode.normal;
|
||||
@override
|
||||
bool get cursorBlinkMode => true;
|
||||
@override
|
||||
bool get cursorVisibleMode => true;
|
||||
@override
|
||||
bool get appKeypadMode => false;
|
||||
@override
|
||||
bool get reportFocusMode => false;
|
||||
@override
|
||||
bool get altBufferMouseScrollMode => false;
|
||||
@override
|
||||
bool get bracketedPasteMode => false;
|
||||
}
|
||||
|
||||
Buffer _newBuffer({
|
||||
int viewWidth = 10,
|
||||
int viewHeight = 5,
|
||||
int maxLines = 50,
|
||||
bool isAltBuffer = false,
|
||||
bool autoWrapMode = true,
|
||||
bool lineFeedMode = false,
|
||||
bool originMode = false,
|
||||
bool reflowEnabled = false,
|
||||
Set<int>? wordSeparators,
|
||||
}) {
|
||||
final state = _State(
|
||||
viewWidth: viewWidth,
|
||||
viewHeight: viewHeight,
|
||||
autoWrapMode: autoWrapMode,
|
||||
lineFeedMode: lineFeedMode,
|
||||
originMode: originMode,
|
||||
reflowEnabled: reflowEnabled,
|
||||
);
|
||||
return Buffer(
|
||||
state,
|
||||
maxLines: maxLines,
|
||||
isAltBuffer: isAltBuffer,
|
||||
wordSeparators: wordSeparators,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Buffer — construction', () {
|
||||
test('seeds viewHeight empty lines and resets margins', () {
|
||||
final b = _newBuffer(viewWidth: 8, viewHeight: 4);
|
||||
expect(b.height, 4);
|
||||
expect(b.viewWidth, 8);
|
||||
expect(b.viewHeight, 4);
|
||||
expect(b.cursorX, 0);
|
||||
expect(b.cursorY, 0);
|
||||
expect(b.scrollBack, 0);
|
||||
expect(b.marginTop, 0);
|
||||
expect(b.marginBottom, 3);
|
||||
});
|
||||
|
||||
test('absolute cursor + margin getters are scrollback-shifted', () {
|
||||
final b = _newBuffer(viewHeight: 3);
|
||||
// Push a line via index() in the no-scrollable-region path on a
|
||||
// primary buffer — that uses lines.push to grow scrollback.
|
||||
b.setCursor(0, 2);
|
||||
b.index();
|
||||
// Now scrollBack == 1; the cursor stayed at viewHeight - 1 = 2.
|
||||
expect(b.scrollBack, 1);
|
||||
expect(b.absoluteCursorY, 3);
|
||||
expect(b.absoluteMarginTop, 1);
|
||||
expect(b.absoluteMarginBottom, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — cursor movement and clamping', () {
|
||||
test('setCursorX/Y clamp to viewWidth-1 / viewHeight-1', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
|
||||
b.setCursorX(99);
|
||||
b.setCursorY(99);
|
||||
expect(b.cursorX, 4);
|
||||
expect(b.cursorY, 2);
|
||||
b.setCursorX(-1);
|
||||
b.setCursorY(-1);
|
||||
expect(b.cursorX, 0);
|
||||
expect(b.cursorY, 0);
|
||||
});
|
||||
|
||||
test('moveCursorX / moveCursorY are relative + clamped', () {
|
||||
final b = _newBuffer();
|
||||
b.setCursor(5, 2);
|
||||
b.moveCursorX(2);
|
||||
b.moveCursorY(1);
|
||||
expect(b.cursorX, 7);
|
||||
expect(b.cursorY, 3);
|
||||
b.moveCursorX(99); // clamps
|
||||
expect(b.cursorX, 9);
|
||||
});
|
||||
|
||||
test('cursorGoForward saturates at viewWidth (one past last visible)', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
b.cursorGoForward();
|
||||
}
|
||||
// The clamped getter caps at viewWidth - 1; the internal saturation
|
||||
// sits at viewWidth.
|
||||
expect(b.cursorX, 4);
|
||||
});
|
||||
|
||||
test('setCursor — originMode shifts y by marginTop and clamps to marginBottom', () {
|
||||
final b = _newBuffer(viewHeight: 6, originMode: true);
|
||||
b.setVerticalMargins(2, 4);
|
||||
b.setCursor(0, 0);
|
||||
// origin shift: cursorY += marginTop (2) → clamped to marginBottom (4).
|
||||
expect(b.cursorY, 2);
|
||||
b.setCursor(0, 99);
|
||||
expect(b.cursorY, 4); // clamped to marginBottom
|
||||
});
|
||||
|
||||
test('moveCursor delegates to setCursor', () {
|
||||
final b = _newBuffer();
|
||||
b.setCursor(0, 0);
|
||||
b.moveCursor(2, 1);
|
||||
expect(b.cursorX, 2);
|
||||
expect(b.cursorY, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — vertical margins', () {
|
||||
test('setVerticalMargins clamps and orders top/bottom', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(99, -5);
|
||||
// Both clamp to [0, viewHeight-1] = [0, 4]; the implementation
|
||||
// also coerces top<=bottom by min/max swap.
|
||||
expect(b.marginTop, lessThanOrEqualTo(b.marginBottom));
|
||||
});
|
||||
|
||||
test('resetVerticalMargins restores full viewport range', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.resetVerticalMargins();
|
||||
expect(b.marginTop, 0);
|
||||
expect(b.marginBottom, 4);
|
||||
});
|
||||
|
||||
test('isInVerticalMargin is true inside, false outside', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(0);
|
||||
expect(b.isInVerticalMargin, isFalse);
|
||||
b.setCursorY(2);
|
||||
expect(b.isInVerticalMargin, isTrue);
|
||||
b.setCursorY(4);
|
||||
expect(b.isInVerticalMargin, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — write / writeChar / autoWrap', () {
|
||||
test('write adds chars left-to-right and advances the cursor', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abc');
|
||||
expect(b.cursorX, 3);
|
||||
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(2), 'c'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('autoWrap pushes the cursor to the next line and marks it wrapped', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
|
||||
b.write('abcdef'); // 'def' wraps to line 1
|
||||
expect(b.height, 4); // still within view; no scrollback needed yet
|
||||
expect(b.cursorY, 1);
|
||||
// cursorX getter clamps to viewWidth-1; internal _cursorX is saturated.
|
||||
expect(b.cursorX, 2);
|
||||
expect(b.currentLine.isWrapped, isTrue);
|
||||
});
|
||||
|
||||
test('autoWrap=false leaves the cursor saturated past the end (no wrap)', () {
|
||||
final b = _newBuffer(viewWidth: 3, autoWrapMode: false);
|
||||
b.write('abc');
|
||||
// After three writes the cursor is at viewWidth (3). The next write
|
||||
// triggers index() but autoWrapMode is off — the line shouldn't be
|
||||
// marked wrapped.
|
||||
b.write('d');
|
||||
expect(b.currentLine.isWrapped, isFalse);
|
||||
});
|
||||
|
||||
test('wide character writes a width-2 cell + a 0-codepoint trailer', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('中'); // CJK ideograph, width 2
|
||||
expect(b.cursorX, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — backspace', () {
|
||||
test('column 0 on a wrapped line jumps to the prior line end and unwraps', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
|
||||
b.write('abcd'); // wraps; cursor at (1, 1) on a wrapped line
|
||||
expect(b.currentLine.isWrapped, isTrue);
|
||||
b.setCursor(0, 1);
|
||||
// Re-flag wrapped (setCursor may not affect it but to be sure)
|
||||
b.currentLine.isWrapped = true;
|
||||
b.backspace();
|
||||
expect(b.cursorX, 2); // viewWidth - 1
|
||||
expect(b.cursorY, 0);
|
||||
});
|
||||
|
||||
test('column == viewWidth backspaces by 2 cells (off the saturated edge)', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
// Saturate the cursor.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
b.cursorGoForward();
|
||||
}
|
||||
// Internal _cursorX is now 5 (viewWidth). The clamped getter shows 4.
|
||||
b.backspace();
|
||||
// -2 of (5) clamps to (3). cursorX getter returns clamped value.
|
||||
expect(b.cursorX, 3);
|
||||
});
|
||||
|
||||
test('mid-line backspace is just a -1', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.setCursor(3, 0);
|
||||
b.backspace();
|
||||
expect(b.cursorX, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — erase commands', () {
|
||||
test('eraseLine clears the whole current line and clears wrapped flag', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abc');
|
||||
b.currentLine.isWrapped = true;
|
||||
b.eraseLine();
|
||||
expect(b.currentLine.isWrapped, isFalse);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
expect(b.currentLine.getCodePoint(i), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('eraseLineFromCursor only clears [cursor, viewWidth)', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abcde');
|
||||
b.setCursorX(2);
|
||||
b.eraseLineFromCursor();
|
||||
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(2), 0);
|
||||
expect(b.currentLine.getCodePoint(4), 0);
|
||||
});
|
||||
|
||||
test('eraseLineToCursor only clears [0, cursor)', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abcde');
|
||||
b.setCursorX(3);
|
||||
b.eraseLineToCursor();
|
||||
expect(b.currentLine.getCodePoint(0), 0);
|
||||
expect(b.currentLine.getCodePoint(2), 0);
|
||||
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('eraseChars erases [cursor, cursor+count)', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abcde');
|
||||
b.setCursorX(1);
|
||||
b.eraseChars(2);
|
||||
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(1), 0);
|
||||
expect(b.currentLine.getCodePoint(2), 0);
|
||||
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('eraseDisplay clears every visible line', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
|
||||
b.write('aaaaa');
|
||||
b.lineFeed();
|
||||
b.write('bbbbb');
|
||||
b.eraseDisplay();
|
||||
for (var y = 0; y < b.viewHeight; y++) {
|
||||
for (var x = 0; x < b.viewWidth; x++) {
|
||||
expect(b.lines[y].getCodePoint(x), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('eraseDisplayFromCursor clears current line tail + every line below', () {
|
||||
// lineFeedMode: true so lineFeed resets cursorX — otherwise the
|
||||
// saturated cursorX would make the next write wrap onto an extra
|
||||
// line via writeChar's autoWrap path.
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
|
||||
b.write('aaaaa');
|
||||
b.lineFeed();
|
||||
b.write('bbbbb');
|
||||
b.lineFeed();
|
||||
b.write('ccccc');
|
||||
b.setCursor(2, 1);
|
||||
b.eraseDisplayFromCursor();
|
||||
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0)); // untouched
|
||||
expect(b.lines[1].getCodePoint(0), 'b'.codeUnitAt(0)); // head kept
|
||||
expect(b.lines[1].getCodePoint(2), 0); // tail erased
|
||||
expect(b.lines[2].getCodePoint(0), 0); // line below wiped
|
||||
});
|
||||
|
||||
test('eraseDisplayToCursor clears every line above + the current line head', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
|
||||
b.write('aaaaa');
|
||||
b.lineFeed();
|
||||
b.write('bbbbb');
|
||||
b.lineFeed();
|
||||
b.write('vwxyz'); // distinct chars so the kept tail is visible
|
||||
b.setCursor(3, 2);
|
||||
b.eraseDisplayToCursor();
|
||||
expect(b.lines[0].getCodePoint(0), 0);
|
||||
expect(b.lines[1].getCodePoint(0), 0);
|
||||
expect(b.lines[2].getCodePoint(0), 0); // head erased
|
||||
expect(b.lines[2].getCodePoint(3), 'y'.codeUnitAt(0)); // tail kept
|
||||
expect(b.lines[2].getCodePoint(4), 'z'.codeUnitAt(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — scroll / index / lineFeed / reverseIndex', () {
|
||||
test('scrollUp inside the scroll region rotates lines and seeds an empty tail', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
|
||||
b.write('aaa');
|
||||
b.lineFeed();
|
||||
b.write('bbb');
|
||||
b.lineFeed();
|
||||
b.write('ccc');
|
||||
b.scrollUp(1);
|
||||
// After scroll: line 0 = 'bbb', line 1 = 'ccc', line 2 = empty.
|
||||
expect(b.lines[0].getCodePoint(0), 'b'.codeUnitAt(0));
|
||||
expect(b.lines[1].getCodePoint(0), 'c'.codeUnitAt(0));
|
||||
expect(b.lines[2].getCodePoint(0), 0);
|
||||
});
|
||||
|
||||
test('scrollDown inside the scroll region rotates lines and seeds an empty head', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
|
||||
b.write('aaa');
|
||||
b.lineFeed();
|
||||
b.write('bbb');
|
||||
b.scrollDown(1);
|
||||
// After scroll: line 0 = empty, line 1 = 'aaa', line 2 = 'bbb'.
|
||||
expect(b.lines[0].getCodePoint(0), 0);
|
||||
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('index — at the bottom of primary buffer pushes a new line to grow scrollback', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
|
||||
b.setCursorY(2);
|
||||
b.index();
|
||||
expect(b.scrollBack, 1);
|
||||
expect(b.cursorY, 2); // stayed at viewHeight - 1
|
||||
});
|
||||
|
||||
test('index — at the bottom of alt buffer scrolls up instead of growing', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3, isAltBuffer: true);
|
||||
b.setCursorY(2);
|
||||
b.index();
|
||||
expect(b.scrollBack, 0);
|
||||
expect(b.cursorY, 2);
|
||||
});
|
||||
|
||||
test('index — within margin but not at the bottom moves cursor down', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(1);
|
||||
b.index();
|
||||
expect(b.cursorY, 2);
|
||||
});
|
||||
|
||||
test('index — at margin bottom with non-zero top scrollUps inside the region', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
|
||||
b.setVerticalMargins(1, 2);
|
||||
b.setCursorY(2); // at marginBottom
|
||||
b.write('a'); // mark line 2
|
||||
b.index();
|
||||
expect(b.cursorY, 2);
|
||||
});
|
||||
|
||||
test('index — at marginBottom with marginTop=0 on primary inserts a new line', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
|
||||
// marginTop=0 by default
|
||||
b.setCursorY(b.marginBottom);
|
||||
b.write('z');
|
||||
final beforeHeight = b.height;
|
||||
b.index();
|
||||
expect(b.height, beforeHeight + 1); // an empty line was inserted
|
||||
});
|
||||
|
||||
test('index — cursor outside the vertical margin and at viewport bottom on primary pushes scrollback', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
|
||||
// Margins shrink the scroll region — the cursor below it is outside.
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(4); // viewHeight - 1, outside [1, 3]
|
||||
final before = b.height;
|
||||
b.index();
|
||||
expect(b.height, before + 1); // primary path: lines.push
|
||||
});
|
||||
|
||||
test('index — cursor outside the vertical margin on alt buffer scrolls up instead of pushing', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5, isAltBuffer: true);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(4);
|
||||
final before = b.height;
|
||||
b.index();
|
||||
expect(b.height, before); // alt path: no growth
|
||||
});
|
||||
|
||||
test('index — cursor outside the vertical margin and not at the bottom moves cursor down', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(4); // outside, but not viewHeight-1... wait viewHeight-1 IS 4
|
||||
// To exercise the moveCursorY branch outside the margin we need
|
||||
// _cursorY < viewHeight - 1 AND outside [marginTop, marginBottom].
|
||||
// With margins (1, 2) the cursor at y=3 is outside and not at the bottom.
|
||||
b.setVerticalMargins(1, 2);
|
||||
b.setCursorY(3);
|
||||
b.index();
|
||||
expect(b.cursorY, 4);
|
||||
});
|
||||
|
||||
test('lineFeed honours lineFeedMode by setting cursor to column 0', () {
|
||||
final b = _newBuffer(lineFeedMode: true);
|
||||
b.setCursorX(5);
|
||||
b.lineFeed();
|
||||
expect(b.cursorX, 0);
|
||||
});
|
||||
|
||||
test('lineFeed leaves cursorX alone when lineFeedMode is off', () {
|
||||
final b = _newBuffer();
|
||||
b.setCursorX(5);
|
||||
b.lineFeed();
|
||||
expect(b.cursorX, 5);
|
||||
});
|
||||
|
||||
test('reverseIndex inside margins at top scrolls down, otherwise moves up', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursorY(2);
|
||||
b.reverseIndex();
|
||||
expect(b.cursorY, 1);
|
||||
|
||||
b.setCursorY(1); // at marginTop
|
||||
b.reverseIndex();
|
||||
expect(b.cursorY, 1); // cursor stays; scrollDown happened
|
||||
|
||||
b.setCursorY(0); // outside margins
|
||||
b.reverseIndex();
|
||||
expect(b.cursorY, 0); // already at 0, clamps
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — saveCursor / restoreCursor', () {
|
||||
test('round-trips position + style + charset', () {
|
||||
final b = _newBuffer();
|
||||
b.setCursor(3, 2);
|
||||
// Mutate cursor style.
|
||||
// (Reach into the underlying state via the property surface.)
|
||||
b.saveCursor();
|
||||
b.setCursor(0, 0);
|
||||
b.restoreCursor();
|
||||
expect(b.cursorX, 3);
|
||||
expect(b.cursorY, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — line insert / delete / chars', () {
|
||||
test('insertBlankChars opens a gap at the cursor', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abcde');
|
||||
b.setCursorX(1);
|
||||
b.insertBlankChars(2);
|
||||
expect(b.currentLine.getCodePoint(1), 0);
|
||||
expect(b.currentLine.getCodePoint(2), 0);
|
||||
expect(b.currentLine.getCodePoint(3), 'b'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('deleteChars closes a gap at the cursor', () {
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('abcde');
|
||||
b.setCursorX(1);
|
||||
b.deleteChars(2);
|
||||
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(1), 'd'.codeUnitAt(0));
|
||||
expect(b.currentLine.getCodePoint(2), 'e'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('insertLines is a no-op outside the scroll region', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(2, 4);
|
||||
b.setCursorY(0);
|
||||
b.write('aaa');
|
||||
b.insertLines(2);
|
||||
// line 0 untouched (cursor was outside margins → no-op)
|
||||
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('insertLines inside the scroll region pushes lines down', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursor(0, 2);
|
||||
b.write('aaa');
|
||||
b.setCursor(0, 1);
|
||||
b.insertLines(1);
|
||||
// insertLines(1) pushes lines below the cursor down. So 'aaa'
|
||||
// should now be at line 3.
|
||||
expect(b.lines[3].getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
// Line 1 (cursor) is now empty.
|
||||
expect(b.lines[1].getCodePoint(0), 0);
|
||||
});
|
||||
|
||||
test('insertLines fills the entire region with empty lines when count >= lines below', () {
|
||||
// When linesToInsert == linesBelow, linesToMove is 0 and the
|
||||
// "fill empty" loop runs from 0 to linesToInsert.
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
|
||||
b.setVerticalMargins(1, 3);
|
||||
b.setCursor(0, 1);
|
||||
b.write('aaa');
|
||||
b.setCursor(0, 2);
|
||||
b.write('bbb');
|
||||
b.setCursor(0, 1);
|
||||
// 3 lines below (incl cursor) but request many more — clamps to 3
|
||||
// and skips the "move" loop entirely.
|
||||
b.insertLines(99);
|
||||
for (var y = 1; y <= 3; y++) {
|
||||
expect(b.lines[y].getCodePoint(0), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteLines is a no-op outside the scroll region', () {
|
||||
final b = _newBuffer(viewHeight: 5);
|
||||
b.setVerticalMargins(2, 4);
|
||||
b.setCursorY(0);
|
||||
b.write('aaa');
|
||||
b.deleteLines(2);
|
||||
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('deleteLines inside the scroll region pulls lines up', () {
|
||||
// Use setCursor (not setCursorY) so cursorX is reset between
|
||||
// writes — otherwise it stays saturated after the previous write.
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 6);
|
||||
b.setVerticalMargins(1, 4);
|
||||
b.setCursor(0, 2);
|
||||
b.write('aaa');
|
||||
b.setCursor(0, 3);
|
||||
b.write('bbb');
|
||||
b.setCursor(0, 1);
|
||||
b.deleteLines(1);
|
||||
// Inside the scroll region, deleteLines at line 1 shifts lines 2..4 up.
|
||||
// Original content was at lines 2 ('aaa') and 3 ('bbb'); after deletion
|
||||
// they should sit at 1 and 2.
|
||||
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
|
||||
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
|
||||
expect(b.lines[4].getCodePoint(0), 0); // bottom of region cleared
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — resize', () {
|
||||
test('grow height pushes empty lines while there is room in the ring', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 3, maxLines: 50);
|
||||
b.resize(5, 3, 5, 5);
|
||||
expect(b.height, 5);
|
||||
});
|
||||
|
||||
test('grow height into existing scrollback bumps cursorY instead of pushing', () {
|
||||
// Build up scrollback so lines.length > new viewHeight.
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3, maxLines: 50);
|
||||
// Force scrollback growth via index()-at-bottom on primary buffer.
|
||||
b.setCursorY(2);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
b.index();
|
||||
}
|
||||
expect(b.height, greaterThanOrEqualTo(8));
|
||||
final cursorYBefore = b.cursorY;
|
||||
// Resize to a height that's still smaller than lines.length: the
|
||||
// grow loop should bump _cursorY rather than push new lines.
|
||||
b.resize(3, 3, 3, 5);
|
||||
expect(b.cursorY, greaterThan(cursorYBefore));
|
||||
});
|
||||
|
||||
test('resize with reflow pads the result up to newHeight', () {
|
||||
// Empty buffer: reflow returns near-zero lines; the pad-with-empty-
|
||||
// lines branch fills up to newHeight.
|
||||
final b = _newBuffer(viewWidth: 8, viewHeight: 3, reflowEnabled: true);
|
||||
b.resize(8, 3, 4, 12);
|
||||
expect(b.height, greaterThanOrEqualTo(12));
|
||||
});
|
||||
|
||||
test('shrink height pops lines from the bottom when cursor is near the top', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
|
||||
b.setCursorY(0);
|
||||
b.resize(5, 5, 5, 3);
|
||||
expect(b.height, 3);
|
||||
});
|
||||
|
||||
test('shrink height drags cursor up when it sits below the new height', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
|
||||
b.setCursorY(4);
|
||||
b.resize(5, 5, 5, 3);
|
||||
expect(b.cursorY, lessThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
test('resize without reflow forwards width to every line', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: false);
|
||||
b.resize(5, 3, 8, 3);
|
||||
for (var y = 0; y < b.viewHeight; y++) {
|
||||
expect(b.lines[y].length, 8);
|
||||
}
|
||||
});
|
||||
|
||||
test('resize with reflow on primary buffer pads the result up to newHeight', () {
|
||||
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: true);
|
||||
b.resize(5, 3, 8, 5);
|
||||
expect(b.height, greaterThanOrEqualTo(5));
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — scrollback / clear', () {
|
||||
test('clearScrollback is a no-op when there is no scrollback', () {
|
||||
final b = _newBuffer(viewHeight: 3);
|
||||
b.clearScrollback();
|
||||
expect(b.scrollBack, 0);
|
||||
});
|
||||
|
||||
test('clearScrollback drops lines above the viewport', () {
|
||||
final b = _newBuffer(viewHeight: 3);
|
||||
// Force scrollback by indexing past the bottom.
|
||||
b.setCursorY(2);
|
||||
b.index();
|
||||
b.index();
|
||||
expect(b.scrollBack, 2);
|
||||
b.clearScrollback();
|
||||
expect(b.scrollBack, 0);
|
||||
});
|
||||
|
||||
test('clear wipes everything and refills with viewHeight empty lines', () {
|
||||
final b = _newBuffer(viewHeight: 3);
|
||||
b.write('aaa');
|
||||
b.clear();
|
||||
expect(b.height, 3);
|
||||
for (var y = 0; y < 3; y++) {
|
||||
for (var x = 0; x < b.viewWidth; x++) {
|
||||
expect(b.lines[y].getCodePoint(x), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — anchors', () {
|
||||
test('createAnchor at (x, y)', () {
|
||||
final b = _newBuffer();
|
||||
final a = b.createAnchor(2, 1);
|
||||
expect(a.x, 2);
|
||||
expect(b.lines[1].anchors, contains(a));
|
||||
});
|
||||
|
||||
test('createAnchorFromOffset', () {
|
||||
final b = _newBuffer();
|
||||
final a = b.createAnchorFromOffset(const CellOffset(3, 2));
|
||||
expect(a.x, 3);
|
||||
expect(b.lines[2].anchors, contains(a));
|
||||
});
|
||||
|
||||
test('createAnchorFromCursor', () {
|
||||
final b = _newBuffer();
|
||||
b.setCursor(4, 1);
|
||||
final a = b.createAnchorFromCursor();
|
||||
expect(a.x, 4);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — getWordBoundary', () {
|
||||
test('returns null for an out-of-range y', () {
|
||||
final b = _newBuffer(viewHeight: 3);
|
||||
expect(b.getWordBoundary(const CellOffset(0, 99)), isNull);
|
||||
});
|
||||
|
||||
test('returns null when the position is fully bounded by separators on both sides', () {
|
||||
// The implementation walks left until a separator (or column 0) and
|
||||
// right until a separator (or viewWidth). A null result requires the
|
||||
// walks to land on the same column — i.e., separators flanking the
|
||||
// position itself.
|
||||
final b = _newBuffer(viewWidth: 5);
|
||||
b.write('a b'); // spaces at indexes 1, 2, 3
|
||||
final r = b.getWordBoundary(const CellOffset(2, 0));
|
||||
expect(r, isNull);
|
||||
});
|
||||
|
||||
test('returns the surrounding word range, halting at separators', () {
|
||||
final b = _newBuffer(viewWidth: 9);
|
||||
b.write('foo bar');
|
||||
final r = b.getWordBoundary(const CellOffset(5, 0))!;
|
||||
expect(r.begin.x, 4);
|
||||
expect(r.end.x, 7);
|
||||
});
|
||||
|
||||
test('honours custom wordSeparators when provided', () {
|
||||
final b = _newBuffer(viewWidth: 5, wordSeparators: <int>{','.codeUnitAt(0)});
|
||||
b.write('a,bcd');
|
||||
final r = b.getWordBoundary(const CellOffset(2, 0))!;
|
||||
expect(r.begin.x, 2);
|
||||
expect(r.end.x, 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — getText', () {
|
||||
test('default range walks the whole buffer line-by-line with newlines', () {
|
||||
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
|
||||
b.write('aa');
|
||||
b.lineFeed();
|
||||
b.write('bb');
|
||||
final text = b.getText();
|
||||
expect(text, contains('aa'));
|
||||
expect(text, contains('bb'));
|
||||
expect(text.split('\n').length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
test('explicit range works on a normalized BufferRangeLine', () {
|
||||
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
|
||||
b.write('aa');
|
||||
b.lineFeed();
|
||||
b.write('bb');
|
||||
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 0));
|
||||
expect(b.getText(r), 'aa');
|
||||
});
|
||||
|
||||
test('skips out-of-range segments', () {
|
||||
final b = _newBuffer(viewWidth: 4, viewHeight: 2);
|
||||
b.write('aa');
|
||||
// Range references a y past height; getText should skip it without
|
||||
// throwing.
|
||||
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 99));
|
||||
final result = b.getText(r);
|
||||
expect(result, contains('aa'));
|
||||
});
|
||||
|
||||
test('wrapped lines do not get an inserted newline', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
|
||||
b.write('abcdef'); // wraps line 1
|
||||
final text = b.getText();
|
||||
// The wrapped line shouldn't have a separating newline before it.
|
||||
expect(text.replaceAll('\n', '').contains('abcdef'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer — toString debug dump', () {
|
||||
test('produces one line per buffer line with index and wrap marker', () {
|
||||
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
|
||||
b.write('abcd'); // wraps; line 0 is 'abc', line 1 is 'd' (wrapped)
|
||||
final out = b.toString();
|
||||
expect(out, contains('|abc|'));
|
||||
expect(out, contains('(⏎)')); // the wrapped marker
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/// Pure-Dart tests for BufferLine + CellAnchor (lib/src/terminal/src/core/buffer/line.dart).
|
||||
library;
|
||||
|
||||
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/cell.dart';
|
||||
import 'package:clide/src/terminal/src/core/cursor.dart';
|
||||
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const int _aChar = 0x61;
|
||||
const int _bChar = 0x62;
|
||||
const int _wideChar = 0x4E2D; // any CJK ideograph; wcwidth=2
|
||||
final _styleEmpty = CursorStyle.empty;
|
||||
|
||||
CursorStyle _style({int fg = 0, int bg = 0, int attrs = 0}) => CursorStyle(foreground: fg, background: bg, attrs: attrs);
|
||||
|
||||
void main() {
|
||||
group('BufferLine — construction and basic accessors', () {
|
||||
test('new line has the requested length, isWrapped false by default', () {
|
||||
final l = BufferLine(10);
|
||||
expect(l.length, 10);
|
||||
expect(l.isWrapped, isFalse);
|
||||
expect(l.anchors, isEmpty);
|
||||
expect(l.attached, isFalse);
|
||||
});
|
||||
|
||||
test('isWrapped can be set via the constructor', () {
|
||||
final l = BufferLine(5, isWrapped: true);
|
||||
expect(l.isWrapped, isTrue);
|
||||
});
|
||||
|
||||
test('data getter exposes the backing Uint32List', () {
|
||||
final l = BufferLine(4);
|
||||
expect(l.data.length, isPositive);
|
||||
expect(l.data.length % 4, 0); // _cellSize == 4
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — per-cell get/set round-trips', () {
|
||||
test('foreground/background/attributes/content are independent', () {
|
||||
final l = BufferLine(3);
|
||||
l.setForeground(0, 0xAA);
|
||||
l.setBackground(0, 0xBB);
|
||||
l.setAttributes(0, 0xCC);
|
||||
l.setContent(0, 0xDD);
|
||||
expect(l.getForeground(0), 0xAA);
|
||||
expect(l.getBackground(0), 0xBB);
|
||||
expect(l.getAttributes(0), 0xCC);
|
||||
expect(l.getContent(0), 0xDD);
|
||||
|
||||
// Other cells are untouched.
|
||||
expect(l.getForeground(1), 0);
|
||||
expect(l.getContent(2), 0);
|
||||
});
|
||||
|
||||
test('setCodePoint encodes width into the high bits of content', () {
|
||||
final l = BufferLine(3);
|
||||
l.setCodePoint(0, _aChar);
|
||||
expect(l.getCodePoint(0), _aChar);
|
||||
expect(l.getWidth(0), 1);
|
||||
|
||||
l.setCodePoint(1, _wideChar);
|
||||
expect(l.getCodePoint(1), _wideChar);
|
||||
expect(l.getWidth(1), 2);
|
||||
});
|
||||
|
||||
test('setCell writes style + char + width as a single packed cell', () {
|
||||
final l = BufferLine(2);
|
||||
l.setCell(0, _aChar, 1, _style(fg: 1, bg: 2, attrs: 4));
|
||||
expect(l.getForeground(0), 1);
|
||||
expect(l.getBackground(0), 2);
|
||||
expect(l.getAttributes(0), 4);
|
||||
expect(l.getCodePoint(0), _aChar);
|
||||
expect(l.getWidth(0), 1);
|
||||
});
|
||||
|
||||
test('getCellData fills a CellData record from the backing buffer', () {
|
||||
final l = BufferLine(1);
|
||||
l.setCell(0, _aChar, 1, _style(fg: 9, bg: 8, attrs: 7));
|
||||
final data = CellData.empty();
|
||||
l.getCellData(0, data);
|
||||
expect(data.foreground, 9);
|
||||
expect(data.background, 8);
|
||||
expect(data.flags, 7);
|
||||
expect(data.content & CellContent.codepointMask, _aChar);
|
||||
});
|
||||
|
||||
test('setCellData writes a CellData back into the backing buffer', () {
|
||||
final l = BufferLine(1);
|
||||
final src = CellData(foreground: 11, background: 22, flags: 33, content: 44);
|
||||
l.setCellData(0, src);
|
||||
expect(l.getForeground(0), 11);
|
||||
expect(l.getBackground(0), 22);
|
||||
expect(l.getAttributes(0), 33);
|
||||
expect(l.getContent(0), 44);
|
||||
});
|
||||
|
||||
test('createCellData seeds an empty CellData and writes it through', () {
|
||||
final l = BufferLine(1);
|
||||
l.setCell(0, _aChar, 1, _style(fg: 5));
|
||||
final result = l.createCellData(0);
|
||||
// createCellData starts from CellData.empty() and writes that into the
|
||||
// backing buffer — it OVERWRITES whatever was at that index.
|
||||
expect(result.foreground, 0);
|
||||
expect(l.getForeground(0), 0);
|
||||
expect(l.getCodePoint(0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — eraseCell + resetCell', () {
|
||||
test('eraseCell stamps style fg/bg/attrs and zeroes content', () {
|
||||
final l = BufferLine(1);
|
||||
l.setCell(0, _aChar, 1, _style(fg: 1, bg: 2, attrs: 4));
|
||||
l.eraseCell(0, _style(fg: 7, bg: 8, attrs: 9));
|
||||
expect(l.getForeground(0), 7);
|
||||
expect(l.getBackground(0), 8);
|
||||
expect(l.getAttributes(0), 9);
|
||||
expect(l.getContent(0), 0);
|
||||
});
|
||||
|
||||
test('resetCell zeroes every channel', () {
|
||||
final l = BufferLine(1);
|
||||
l.setCell(0, _aChar, 1, _style(fg: 1, bg: 2, attrs: 4));
|
||||
l.resetCell(0);
|
||||
expect(l.getForeground(0), 0);
|
||||
expect(l.getBackground(0), 0);
|
||||
expect(l.getAttributes(0), 0);
|
||||
expect(l.getContent(0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — eraseRange', () {
|
||||
test('basic range erases [start, end)', () {
|
||||
final l = BufferLine(5);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
l.setCell(i, _aChar, 1, _styleEmpty);
|
||||
}
|
||||
l.eraseRange(1, 4, _styleEmpty);
|
||||
expect(l.getCodePoint(0), _aChar);
|
||||
expect(l.getCodePoint(1), 0);
|
||||
expect(l.getCodePoint(2), 0);
|
||||
expect(l.getCodePoint(3), 0);
|
||||
expect(l.getCodePoint(4), _aChar); // outside the [1,4) range
|
||||
});
|
||||
|
||||
test('clamps end to length', () {
|
||||
final l = BufferLine(3);
|
||||
l.setCell(0, _aChar, 1, _styleEmpty);
|
||||
l.setCell(2, _aChar, 1, _styleEmpty);
|
||||
l.eraseRange(0, 99, _styleEmpty);
|
||||
expect(l.getCodePoint(0), 0);
|
||||
expect(l.getCodePoint(2), 0);
|
||||
});
|
||||
|
||||
test('extends one cell left when start-1 is the second cell of a wide char', () {
|
||||
final l = BufferLine(4);
|
||||
// Place a wide char at positions 0..1, then a regular at 2.
|
||||
l.setCell(0, _wideChar, 2, _styleEmpty);
|
||||
l.setCell(1, 0, 0, _styleEmpty); // (logical second-half marker; width=0)
|
||||
// The implementation only checks getWidth(start-1) == 2, so make it true.
|
||||
l.setContent(0, _wideChar | (2 << CellContent.widthShift));
|
||||
l.setCell(2, _aChar, 1, _styleEmpty);
|
||||
|
||||
l.eraseRange(2, 3, _styleEmpty);
|
||||
// Cell at index 1 is start-1; getWidth(1) is 0 here, so the wide-extension
|
||||
// branch isn't hit through index 1. But the branch for getWidth(start-1)==2
|
||||
// is exercised when start lands on a cell whose neighbor at start-1 is wide.
|
||||
expect(l.getCodePoint(2), 0); // erased
|
||||
|
||||
// Now exercise the actual wide-neighbor branch: erase range [1, 2) when
|
||||
// index 0 is wide.
|
||||
l.setContent(0, _wideChar | (2 << CellContent.widthShift));
|
||||
l.setCell(1, _aChar, 1, _styleEmpty);
|
||||
l.eraseRange(1, 2, _styleEmpty);
|
||||
// index 0's content should now also be erased due to the extension.
|
||||
expect(l.getContent(0), 0);
|
||||
});
|
||||
|
||||
test('extends one cell right when end-1 is the second cell of a wide char', () {
|
||||
final l = BufferLine(4);
|
||||
l.setCell(0, _aChar, 1, _styleEmpty);
|
||||
// Place a wide char straddling indexes 1..2 (width recorded at index 1).
|
||||
l.setContent(1, _wideChar | (2 << CellContent.widthShift));
|
||||
l.setCell(3, _aChar, 1, _styleEmpty);
|
||||
|
||||
l.eraseRange(0, 2, _styleEmpty);
|
||||
// Both index 0 (in range) and index 1 (the wide-end-extension) should be
|
||||
// erased to 0.
|
||||
expect(l.getContent(0), 0);
|
||||
expect(l.getContent(1), 0);
|
||||
// Index 3 stays.
|
||||
expect(l.getCodePoint(3), _aChar);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — removeCells', () {
|
||||
test('shifts cells left and fills tail with the given style', () {
|
||||
final l = BufferLine(5);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
l.setCell(i, _aChar + i, 1, _styleEmpty);
|
||||
}
|
||||
l.removeCells(1, 2);
|
||||
// After removeCells(1, 2), cells at index 3..4 move to 1..2.
|
||||
expect(l.getCodePoint(0), _aChar); // index 0 unchanged
|
||||
expect(l.getCodePoint(1), _aChar + 3);
|
||||
expect(l.getCodePoint(2), _aChar + 4);
|
||||
expect(l.getCodePoint(3), 0); // erased tail
|
||||
expect(l.getCodePoint(4), 0);
|
||||
});
|
||||
|
||||
test('null style argument defaults to CursorStyle.empty', () {
|
||||
final l = BufferLine(3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
l.setCell(i, _aChar, 1, _style(fg: 99));
|
||||
}
|
||||
l.removeCells(0, 1); // default style = empty
|
||||
// Tail cell after shift should be erased with empty style.
|
||||
expect(l.getForeground(2), 0);
|
||||
});
|
||||
|
||||
test('handles wide neighbor at start-1 (erases preceding wide cell)', () {
|
||||
final l = BufferLine(4);
|
||||
l.setContent(0, _wideChar | (2 << CellContent.widthShift));
|
||||
l.setCell(1, _aChar, 1, _styleEmpty);
|
||||
l.setCell(2, _bChar, 1, _styleEmpty);
|
||||
l.setCell(3, _aChar, 1, _styleEmpty);
|
||||
l.removeCells(1, 1);
|
||||
expect(l.getContent(0), 0); // wide neighbor erased
|
||||
});
|
||||
|
||||
test('removes anchors inside the removed range, repositions later anchors', () {
|
||||
final l = BufferLine(5);
|
||||
final inside = l.createAnchor(2);
|
||||
final after = l.createAnchor(4);
|
||||
final before = l.createAnchor(0);
|
||||
|
||||
l.removeCells(1, 2);
|
||||
|
||||
expect(inside.attached, isFalse); // disposed
|
||||
expect(after.x, 4 - 2); // moved left by count
|
||||
expect(before.x, 0); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — insertCells', () {
|
||||
test('shifts cells right, erases inserted range', () {
|
||||
final l = BufferLine(5);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
l.setCell(i, _aChar + i, 1, _styleEmpty);
|
||||
}
|
||||
l.insertCells(1, 2);
|
||||
expect(l.getCodePoint(0), _aChar);
|
||||
expect(l.getCodePoint(1), 0); // erased (newly inserted)
|
||||
expect(l.getCodePoint(2), 0); // erased (newly inserted)
|
||||
expect(l.getCodePoint(3), _aChar + 1); // shifted from index 1
|
||||
expect(l.getCodePoint(4), _aChar + 2); // shifted from index 2
|
||||
});
|
||||
|
||||
test('null style argument defaults to CursorStyle.empty', () {
|
||||
final l = BufferLine(3);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
l.setCell(i, _aChar, 1, _style(fg: 99));
|
||||
}
|
||||
l.insertCells(0, 1);
|
||||
// newly inserted cell at 0 should have empty style.
|
||||
expect(l.getForeground(0), 0);
|
||||
});
|
||||
|
||||
test('handles wide neighbor at start-1', () {
|
||||
final l = BufferLine(4);
|
||||
l.setContent(0, _wideChar | (2 << CellContent.widthShift));
|
||||
l.setCell(1, _aChar, 1, _styleEmpty);
|
||||
l.insertCells(1, 1);
|
||||
expect(l.getContent(0), 0); // wide neighbor erased
|
||||
});
|
||||
|
||||
test('handles wide cell pushed to the line tail by the shift', () {
|
||||
// Wide cell at index 2 (length-1-count). After insertCells(0, 1)
|
||||
// shifts data[0..2] to data[1..3], the wide marker lands at index
|
||||
// 3 — which is now the last cell. The wide-tail-erase branch
|
||||
// (getWidth(_length - 1) == 2) fires and clears it.
|
||||
final l = BufferLine(4);
|
||||
l.setContent(2, _wideChar | (2 << CellContent.widthShift));
|
||||
l.insertCells(0, 1);
|
||||
expect(l.getContent(3), 0);
|
||||
});
|
||||
|
||||
test('repositions anchors at-or-past the end of the inserted range', () {
|
||||
final l = BufferLine(8);
|
||||
// insertCells(1, 2): only anchors with x >= start + count = 3 move.
|
||||
final inRange = l.createAnchor(2); // inside [start, start+count)
|
||||
final atBoundary = l.createAnchor(3); // exactly at start+count
|
||||
final far = l.createAnchor(5);
|
||||
final before = l.createAnchor(0);
|
||||
|
||||
l.insertCells(1, 2);
|
||||
|
||||
expect(inRange.x, 2); // inside the inserted range — stays put
|
||||
expect(atBoundary.x, 5); // at boundary — moves
|
||||
expect(far.x, 7); // moves
|
||||
expect(before.x, 0);
|
||||
});
|
||||
|
||||
test('disposes anchors pushed past the line end', () {
|
||||
final l = BufferLine(4);
|
||||
final farRight = l.createAnchor(3);
|
||||
l.insertCells(1, 2);
|
||||
// 3 → 5, which is >= length (4) → disposed.
|
||||
expect(farRight.attached, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — resize', () {
|
||||
test('same length is a no-op', () {
|
||||
final l = BufferLine(3);
|
||||
final before = l.data.length;
|
||||
l.resize(3);
|
||||
expect(l.length, 3);
|
||||
expect(l.data.length, before);
|
||||
});
|
||||
|
||||
test('shrink keeps capacity, lowers length', () {
|
||||
final l = BufferLine(5);
|
||||
final cap = l.data.length;
|
||||
l.resize(2);
|
||||
expect(l.length, 2);
|
||||
expect(l.data.length, cap);
|
||||
});
|
||||
|
||||
test('grow within capacity does not realloc', () {
|
||||
final l = BufferLine(2);
|
||||
final cap = l.data.length;
|
||||
l.resize(10);
|
||||
expect(l.length, 10);
|
||||
expect(l.data.length, cap); // still the initial 64-cell capacity
|
||||
});
|
||||
|
||||
test('grow beyond capacity reallocates and copies', () {
|
||||
final l = BufferLine(2);
|
||||
l.setCodePoint(0, _aChar);
|
||||
final initialCap = l.data.length;
|
||||
l.resize(500); // forces capacity > initial — exercises the >=256 branch
|
||||
expect(l.length, 500);
|
||||
expect(l.data.length, greaterThan(initialCap));
|
||||
expect(l.getCodePoint(0), _aChar); // content preserved
|
||||
});
|
||||
|
||||
test('grow into the [64, 256) capacity-doubling branch', () {
|
||||
// _calcCapacity grows by *2 while < 256 and < length. Pushing length
|
||||
// to 100 forces the doubling loop (64 → 128).
|
||||
final l = BufferLine(2);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.resize(100);
|
||||
expect(l.length, 100);
|
||||
expect(l.data.length, greaterThanOrEqualTo(128 * 4));
|
||||
expect(l.getCodePoint(0), _aChar);
|
||||
});
|
||||
|
||||
test('clamps anchor x to new length on shrink', () {
|
||||
final l = BufferLine(10);
|
||||
final a = l.createAnchor(8);
|
||||
l.resize(5);
|
||||
expect(a.x, lessThanOrEqualTo(5));
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — getTrimmedLength', () {
|
||||
test('empty line returns 0', () {
|
||||
final l = BufferLine(10);
|
||||
expect(l.getTrimmedLength(), 0);
|
||||
expect(l.getTrimmedLength(0), 0);
|
||||
});
|
||||
|
||||
test('returns last-content-index + width for the last filled cell', () {
|
||||
final l = BufferLine(10);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.setCodePoint(1, _bChar);
|
||||
expect(l.getTrimmedLength(), 2);
|
||||
});
|
||||
|
||||
test('honours wide cells at the end', () {
|
||||
final l = BufferLine(10);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.setCodePoint(1, _wideChar); // width 2
|
||||
expect(l.getTrimmedLength(), 3);
|
||||
});
|
||||
|
||||
test('cols caps the search range', () {
|
||||
final l = BufferLine(10);
|
||||
l.setCodePoint(5, _aChar);
|
||||
expect(l.getTrimmedLength(3), 0); // searched indexes 0..2 only
|
||||
expect(l.getTrimmedLength(6), 6); // includes index 5
|
||||
});
|
||||
|
||||
test('cols larger than capacity falls back to capacity', () {
|
||||
final l = BufferLine(2);
|
||||
l.setCodePoint(0, _aChar);
|
||||
// capacity is 64 cells; passing cols=999 should clamp.
|
||||
expect(l.getTrimmedLength(999), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — copyFrom', () {
|
||||
test('copies cells from src to dst at the right offsets, resizing as needed', () {
|
||||
final src = BufferLine(5);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
src.setCell(i, _aChar + i, 1, _style(fg: i + 1));
|
||||
}
|
||||
final dst = BufferLine(2);
|
||||
dst.copyFrom(src, 1, 0, 3);
|
||||
expect(dst.length, 3);
|
||||
expect(dst.getCodePoint(0), _aChar + 1);
|
||||
expect(dst.getCodePoint(1), _aChar + 2);
|
||||
expect(dst.getCodePoint(2), _aChar + 3);
|
||||
expect(dst.getForeground(0), 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — getText / toString', () {
|
||||
test('empty line returns empty string', () {
|
||||
expect(BufferLine(5).getText(), '');
|
||||
expect(BufferLine(5).toString(), '');
|
||||
});
|
||||
|
||||
test('default args span the whole line', () {
|
||||
final l = BufferLine(3);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.setCodePoint(1, _bChar);
|
||||
expect(l.getText(), 'ab');
|
||||
expect(l.toString(), 'ab');
|
||||
});
|
||||
|
||||
test('explicit from/to slice', () {
|
||||
final l = BufferLine(5);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
l.setCodePoint(i, _aChar + i);
|
||||
}
|
||||
expect(l.getText(1, 3), 'bc');
|
||||
});
|
||||
|
||||
test('clamps from < 0 and to > length', () {
|
||||
final l = BufferLine(3);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.setCodePoint(1, _bChar);
|
||||
expect(l.getText(-1, 99), 'ab');
|
||||
});
|
||||
|
||||
test('skips a wide char that would extend past `to`', () {
|
||||
final l = BufferLine(4);
|
||||
l.setCodePoint(0, _aChar);
|
||||
l.setCodePoint(1, _wideChar); // width 2 — straddles 1..2
|
||||
// Asking for text up to index 2 (exclusive of 2) means the wide char
|
||||
// would extend past `to`; it's skipped.
|
||||
expect(l.getText(0, 2), 'a');
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferLine — anchors', () {
|
||||
test('createAnchor adds to anchors list and returns it owned by the line', () {
|
||||
final l = BufferLine(5);
|
||||
final a = l.createAnchor(2);
|
||||
expect(l.anchors, contains(a));
|
||||
expect(a.line, l);
|
||||
expect(a.x, 2);
|
||||
});
|
||||
|
||||
test('dispose detaches all anchors', () {
|
||||
final l = BufferLine(5);
|
||||
final a = l.createAnchor(1);
|
||||
final b = l.createAnchor(3);
|
||||
l.dispose();
|
||||
expect(a.attached, isFalse);
|
||||
expect(b.attached, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('CellAnchor', () {
|
||||
test('detached anchor: x, line are accessible; attached is false', () {
|
||||
final a = CellAnchor(7);
|
||||
expect(a.x, 7);
|
||||
expect(a.line, isNull);
|
||||
expect(a.attached, isFalse);
|
||||
expect(a.toString(), 'CellAnchor(7, detached)');
|
||||
});
|
||||
|
||||
test('reposition updates x', () {
|
||||
final a = CellAnchor(2);
|
||||
a.reposition(10);
|
||||
expect(a.x, 10);
|
||||
});
|
||||
|
||||
test('reparent moves the anchor between lines', () {
|
||||
final l1 = BufferLine(4);
|
||||
final l2 = BufferLine(4);
|
||||
final a = l1.createAnchor(0);
|
||||
a.reparent(l2, 3);
|
||||
expect(l1.anchors, isNot(contains(a)));
|
||||
expect(l2.anchors, contains(a));
|
||||
expect(a.x, 3);
|
||||
});
|
||||
|
||||
test('reparent from a detached anchor wires it to a new owner', () {
|
||||
final l = BufferLine(4);
|
||||
final a = CellAnchor(0); // no owner
|
||||
a.reparent(l, 2);
|
||||
expect(l.anchors, contains(a));
|
||||
expect(a.x, 2);
|
||||
});
|
||||
|
||||
test('attached anchor exposes y + offset via the owning circular buffer', () {
|
||||
final l = BufferLine(4);
|
||||
// Push the line into a buffer so its `attached` becomes true and
|
||||
// `index` is well-defined.
|
||||
final ring = IndexAwareCircularBuffer<BufferLine>(8)..push(l);
|
||||
expect(l.attached, isTrue);
|
||||
expect(ring.length, 1);
|
||||
|
||||
final a = l.createAnchor(2);
|
||||
expect(a.attached, isTrue);
|
||||
expect(a.y, 0);
|
||||
expect(a.offset, const CellOffset(2, 0));
|
||||
expect(a.toString(), 'CellAnchor(2, 0)');
|
||||
});
|
||||
|
||||
test('dispose detaches an attached anchor', () {
|
||||
final l = BufferLine(4);
|
||||
final a = l.createAnchor(1);
|
||||
a.dispose();
|
||||
expect(a.attached, isFalse);
|
||||
expect(l.anchors, isNot(contains(a)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/// Pure-Dart tests for the BufferRange family (CellOffset, BufferSegment,
|
||||
/// BufferRangeLine, BufferRangeBlock). No Flutter dependency.
|
||||
library;
|
||||
|
||||
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/range_block.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
|
||||
import 'package:clide/src/terminal/src/core/buffer/segment.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('CellOffset', () {
|
||||
test('equality + hashCode for same coordinates', () {
|
||||
const a = CellOffset(3, 5);
|
||||
const b = CellOffset(3, 5);
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a.isEqual(b), isTrue);
|
||||
expect(identical(a, a), isTrue); // hits the identity branch in ==
|
||||
const Object notACellOffset = 'CellOffset(3, 5)';
|
||||
expect(a == notACellOffset, isFalse); // hits the type-mismatch branch
|
||||
});
|
||||
|
||||
test('inequality for different coordinates', () {
|
||||
expect(const CellOffset(3, 5), isNot(const CellOffset(4, 5)));
|
||||
expect(const CellOffset(3, 5).isEqual(const CellOffset(3, 6)), isFalse);
|
||||
});
|
||||
|
||||
test('isBefore / isAfter — different rows', () {
|
||||
const top = CellOffset(9, 1);
|
||||
const bottom = CellOffset(0, 2);
|
||||
expect(top.isBefore(bottom), isTrue);
|
||||
expect(bottom.isAfter(top), isTrue);
|
||||
expect(top.isAfter(bottom), isFalse);
|
||||
expect(bottom.isBefore(top), isFalse);
|
||||
});
|
||||
|
||||
test('isBefore / isAfter — same row, different columns', () {
|
||||
const left = CellOffset(2, 4);
|
||||
const right = CellOffset(7, 4);
|
||||
expect(left.isBefore(right), isTrue);
|
||||
expect(right.isAfter(left), isTrue);
|
||||
expect(left.isAfter(right), isFalse);
|
||||
expect(right.isBefore(left), isFalse);
|
||||
});
|
||||
|
||||
test('isBefore / isAfter — equal positions are neither', () {
|
||||
const a = CellOffset(2, 2);
|
||||
const b = CellOffset(2, 2);
|
||||
expect(a.isBefore(b), isFalse);
|
||||
expect(a.isAfter(b), isFalse);
|
||||
});
|
||||
|
||||
test('isBeforeOrSame / isAfterOrSame include equal positions', () {
|
||||
const a = CellOffset(3, 3);
|
||||
const b = CellOffset(3, 3);
|
||||
const earlier = CellOffset(2, 3);
|
||||
const later = CellOffset(4, 3);
|
||||
expect(a.isBeforeOrSame(b), isTrue);
|
||||
expect(a.isAfterOrSame(b), isTrue);
|
||||
expect(earlier.isBeforeOrSame(a), isTrue);
|
||||
expect(later.isAfterOrSame(a), isTrue);
|
||||
expect(later.isBeforeOrSame(a), isFalse);
|
||||
expect(earlier.isAfterOrSame(a), isFalse);
|
||||
});
|
||||
|
||||
test('isAtSameRow / isAtSameColumn', () {
|
||||
expect(const CellOffset(2, 5).isAtSameRow(const CellOffset(9, 5)), isTrue);
|
||||
expect(const CellOffset(2, 5).isAtSameRow(const CellOffset(2, 6)), isFalse);
|
||||
expect(const CellOffset(4, 1).isAtSameColumn(const CellOffset(4, 9)), isTrue);
|
||||
expect(const CellOffset(4, 1).isAtSameColumn(const CellOffset(5, 9)), isFalse);
|
||||
});
|
||||
|
||||
test('isWithin delegates to BufferRange.contains', () {
|
||||
final range = BufferRangeLine(const CellOffset(0, 0), const CellOffset(5, 0));
|
||||
expect(const CellOffset(3, 0).isWithin(range), isTrue);
|
||||
expect(const CellOffset(3, 1).isWithin(range), isFalse);
|
||||
});
|
||||
|
||||
test('toString shape', () {
|
||||
expect(const CellOffset(2, 3).toString(), 'CellOffset(2, 3)');
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferSegment', () {
|
||||
test('isWithin — wrong line is never within', () {
|
||||
const seg = BufferSegment(_dummyRange, 4, 2, 8);
|
||||
expect(seg.isWithin(const CellOffset(5, 3)), isFalse);
|
||||
expect(seg.isWithin(const CellOffset(5, 5)), isFalse);
|
||||
});
|
||||
|
||||
test('isWithin — bounded segment respects start and end', () {
|
||||
const seg = BufferSegment(_dummyRange, 4, 2, 8);
|
||||
expect(seg.isWithin(const CellOffset(2, 4)), isTrue); // at start
|
||||
expect(seg.isWithin(const CellOffset(5, 4)), isTrue); // middle
|
||||
expect(seg.isWithin(const CellOffset(8, 4)), isTrue); // at end
|
||||
expect(seg.isWithin(const CellOffset(1, 4)), isFalse); // before start
|
||||
expect(seg.isWithin(const CellOffset(9, 4)), isFalse); // after end
|
||||
});
|
||||
|
||||
test('isWithin — null start means line beginning', () {
|
||||
const seg = BufferSegment(_dummyRange, 0, null, 5);
|
||||
expect(seg.isWithin(const CellOffset(0, 0)), isTrue);
|
||||
expect(seg.isWithin(const CellOffset(5, 0)), isTrue);
|
||||
expect(seg.isWithin(const CellOffset(6, 0)), isFalse);
|
||||
});
|
||||
|
||||
test('isWithin — null end means line end', () {
|
||||
const seg = BufferSegment(_dummyRange, 0, 3, null);
|
||||
expect(seg.isWithin(const CellOffset(3, 0)), isTrue);
|
||||
expect(seg.isWithin(const CellOffset(999, 0)), isTrue);
|
||||
expect(seg.isWithin(const CellOffset(2, 0)), isFalse);
|
||||
});
|
||||
|
||||
test('equality + hashCode', () {
|
||||
const a = BufferSegment(_dummyRange, 1, 2, 3);
|
||||
const b = BufferSegment(_dummyRange, 1, 2, 3);
|
||||
const c = BufferSegment(_dummyRange, 1, 2, 4);
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a, isNot(c));
|
||||
const Object notASegment = 'segment';
|
||||
expect(a == notASegment, isFalse);
|
||||
expect(identical(a, a), isTrue);
|
||||
});
|
||||
|
||||
test('toString shape — bounded and unbounded', () {
|
||||
expect(
|
||||
const BufferSegment(_dummyRange, 4, 2, 8).toString(),
|
||||
'Segment(4, 2 -> 8)',
|
||||
);
|
||||
expect(
|
||||
const BufferSegment(_dummyRange, 0, null, null).toString(),
|
||||
'Segment(0, start -> end)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferRangeLine', () {
|
||||
test('collapsed range has equal begin and end', () {
|
||||
final r = BufferRangeLine.collapsed(const CellOffset(2, 3));
|
||||
expect(r.isCollapsed, isTrue);
|
||||
expect(r.begin, const CellOffset(2, 3));
|
||||
expect(r.end, const CellOffset(2, 3));
|
||||
});
|
||||
|
||||
test('isNormalized true when begin <= end', () {
|
||||
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(3, 1));
|
||||
expect(r.isNormalized, isTrue);
|
||||
});
|
||||
|
||||
test('normalized swaps begin/end when reversed', () {
|
||||
final r = BufferRangeLine(const CellOffset(3, 1), const CellOffset(0, 0));
|
||||
expect(r.isNormalized, isFalse);
|
||||
final n = r.normalized;
|
||||
expect(n.begin, const CellOffset(0, 0));
|
||||
expect(n.end, const CellOffset(3, 1));
|
||||
expect(n.normalized, n); // already normalized → identity
|
||||
});
|
||||
|
||||
test('toSegments — single line returns one bounded segment', () {
|
||||
final r = BufferRangeLine(const CellOffset(2, 0), const CellOffset(5, 0));
|
||||
final segs = r.toSegments().toList();
|
||||
expect(segs, hasLength(1));
|
||||
expect(segs[0].line, 0);
|
||||
expect(segs[0].start, 2);
|
||||
expect(segs[0].end, 5);
|
||||
});
|
||||
|
||||
test('toSegments — multiple lines: first bounded-left, last bounded-right, middle unbounded', () {
|
||||
final r = BufferRangeLine(const CellOffset(2, 0), const CellOffset(5, 2));
|
||||
final segs = r.toSegments().toList();
|
||||
expect(segs.map((s) => s.line), [0, 1, 2]);
|
||||
expect(segs[0].start, 2);
|
||||
expect(segs[0].end, isNull);
|
||||
expect(segs[1].start, isNull);
|
||||
expect(segs[1].end, isNull);
|
||||
expect(segs[2].start, isNull);
|
||||
expect(segs[2].end, 5);
|
||||
});
|
||||
|
||||
test('contains positions inside the range, including endpoints', () {
|
||||
final r = BufferRangeLine(const CellOffset(2, 0), const CellOffset(5, 2));
|
||||
expect(r.contains(const CellOffset(2, 0)), isTrue);
|
||||
expect(r.contains(const CellOffset(0, 1)), isTrue); // mid line
|
||||
expect(r.contains(const CellOffset(5, 2)), isTrue);
|
||||
expect(r.contains(const CellOffset(1, 0)), isFalse);
|
||||
expect(r.contains(const CellOffset(6, 2)), isFalse);
|
||||
});
|
||||
|
||||
test('contains works on a denormalized range too (normalizes internally)', () {
|
||||
final r = BufferRangeLine(const CellOffset(5, 2), const CellOffset(2, 0));
|
||||
expect(r.contains(const CellOffset(0, 1)), isTrue);
|
||||
});
|
||||
|
||||
test('merge picks earliest begin and latest end', () {
|
||||
final a = BufferRangeLine(const CellOffset(2, 0), const CellOffset(5, 0));
|
||||
final b = BufferRangeLine(const CellOffset(0, 0), const CellOffset(9, 0));
|
||||
final m = a.merge(b);
|
||||
expect(m.begin, const CellOffset(0, 0));
|
||||
expect(m.end, const CellOffset(9, 0));
|
||||
});
|
||||
|
||||
test('extend grows the range to cover an outside position', () {
|
||||
final r = BufferRangeLine(const CellOffset(2, 1), const CellOffset(5, 1));
|
||||
final earlier = r.extend(const CellOffset(0, 0));
|
||||
expect(earlier.begin, const CellOffset(0, 0));
|
||||
expect(earlier.end, const CellOffset(5, 1));
|
||||
final later = r.extend(const CellOffset(9, 3));
|
||||
expect(later.begin, const CellOffset(2, 1));
|
||||
expect(later.end, const CellOffset(9, 3));
|
||||
});
|
||||
|
||||
test('equality + hashCode + toString', () {
|
||||
final a = BufferRangeLine(const CellOffset(0, 0), const CellOffset(1, 1));
|
||||
final b = BufferRangeLine(const CellOffset(0, 0), const CellOffset(1, 1));
|
||||
final c = BufferRangeLine(const CellOffset(0, 0), const CellOffset(1, 2));
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a, isNot(c));
|
||||
const Object notALineRange = 'line range';
|
||||
expect(a == notALineRange, isFalse);
|
||||
expect(identical(a, a), isTrue);
|
||||
expect(a.toString(), startsWith('Line Range('));
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferRangeBlock', () {
|
||||
test('isNormalized requires top-left + bottom-right corners', () {
|
||||
final tlBr = BufferRangeBlock(const CellOffset(2, 1), const CellOffset(5, 4));
|
||||
expect(tlBr.isNormalized, isTrue);
|
||||
|
||||
final trBl = BufferRangeBlock(const CellOffset(5, 1), const CellOffset(2, 4));
|
||||
expect(trBl.isNormalized, isFalse);
|
||||
});
|
||||
|
||||
test('normalized produces top-left / bottom-right corners', () {
|
||||
final r = BufferRangeBlock(const CellOffset(5, 1), const CellOffset(2, 4));
|
||||
final n = r.normalized;
|
||||
expect(n.begin, const CellOffset(2, 1));
|
||||
expect(n.end, const CellOffset(5, 4));
|
||||
expect(n.normalized, n); // already normalized branch
|
||||
});
|
||||
|
||||
test('toSegments — bounded on every line', () {
|
||||
final r = BufferRangeBlock(const CellOffset(2, 0), const CellOffset(5, 2));
|
||||
final segs = r.toSegments().toList();
|
||||
expect(segs.map((s) => s.line), [0, 1, 2]);
|
||||
for (final s in segs) {
|
||||
expect(s.start, 2);
|
||||
expect(s.end, 5);
|
||||
}
|
||||
});
|
||||
|
||||
test('toSegments — denormalized input still yields normalized output', () {
|
||||
final r = BufferRangeBlock(const CellOffset(5, 2), const CellOffset(2, 0));
|
||||
final segs = r.toSegments().toList();
|
||||
// After internal normalization the segment range is x:[2..5] over y:[0..2].
|
||||
expect(segs.map((s) => s.line), [0, 1, 2]);
|
||||
for (final s in segs) {
|
||||
expect(s.start, 2);
|
||||
expect(s.end, 5);
|
||||
}
|
||||
});
|
||||
|
||||
test('contains — inside, on edge, outside', () {
|
||||
final r = BufferRangeBlock(const CellOffset(2, 0), const CellOffset(5, 2));
|
||||
expect(r.contains(const CellOffset(2, 0)), isTrue); // tl
|
||||
expect(r.contains(const CellOffset(5, 2)), isTrue); // br
|
||||
expect(r.contains(const CellOffset(3, 1)), isTrue); // inside
|
||||
expect(r.contains(const CellOffset(1, 1)), isFalse); // left of block
|
||||
expect(r.contains(const CellOffset(6, 1)), isFalse); // right of block
|
||||
expect(r.contains(const CellOffset(3, 3)), isFalse); // below block
|
||||
});
|
||||
|
||||
test('contains works on denormalized blocks', () {
|
||||
final r = BufferRangeBlock(const CellOffset(5, 2), const CellOffset(2, 0));
|
||||
expect(r.contains(const CellOffset(3, 1)), isTrue);
|
||||
});
|
||||
|
||||
test('extend — position inside is identity', () {
|
||||
final r = BufferRangeBlock(const CellOffset(2, 0), const CellOffset(5, 2));
|
||||
final same = r.extend(const CellOffset(3, 1));
|
||||
expect(same, r);
|
||||
});
|
||||
|
||||
test('extend — position outside grows the block', () {
|
||||
final r = BufferRangeBlock(const CellOffset(2, 1), const CellOffset(5, 3));
|
||||
final out = r.extend(const CellOffset(0, 0));
|
||||
expect(out.begin, const CellOffset(0, 0));
|
||||
expect(out.end, const CellOffset(5, 3));
|
||||
});
|
||||
|
||||
test('merge — combines two blocks into the smallest enclosing block', () {
|
||||
final a = BufferRangeBlock(const CellOffset(0, 0), const CellOffset(2, 2));
|
||||
final b = BufferRangeBlock(const CellOffset(5, 5), const CellOffset(7, 7));
|
||||
final m = a.merge(b);
|
||||
expect(m.begin, const CellOffset(0, 0));
|
||||
expect(m.end, const CellOffset(7, 7));
|
||||
});
|
||||
|
||||
test('equality + hashCode + toString', () {
|
||||
final a = BufferRangeBlock(const CellOffset(0, 0), const CellOffset(1, 1));
|
||||
final b = BufferRangeBlock(const CellOffset(0, 0), const CellOffset(1, 1));
|
||||
final c = BufferRangeBlock(const CellOffset(0, 0), const CellOffset(1, 2));
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a, isNot(c));
|
||||
const Object notABlockRange = 'block range';
|
||||
expect(a == notABlockRange, isFalse);
|
||||
expect(identical(a, a), isTrue);
|
||||
expect(a.toString(), startsWith('Block Range('));
|
||||
});
|
||||
});
|
||||
|
||||
group('BufferRange (abstract base) — collapsed + isCollapsed + isNormalized', () {
|
||||
test('collapsed range is normalized and collapsed', () {
|
||||
final r = BufferRangeLine.collapsed(const CellOffset(3, 3));
|
||||
expect(r.isCollapsed, isTrue);
|
||||
expect(r.isNormalized, isTrue);
|
||||
});
|
||||
|
||||
test('block collapsed constructor', () {
|
||||
final r = BufferRangeBlock.collapsed(const CellOffset(2, 2));
|
||||
expect(r.isCollapsed, isTrue);
|
||||
expect(r.begin, r.end);
|
||||
});
|
||||
|
||||
test('base ==/hashCode/toString via direct subclass that does not override them', () {
|
||||
const a = _StubRange(CellOffset(0, 0), CellOffset(1, 1));
|
||||
const b = _StubRange(CellOffset(0, 0), CellOffset(1, 1));
|
||||
const c = _StubRange(CellOffset(0, 0), CellOffset(2, 2));
|
||||
expect(a, b); // same begin + end via base ==
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a, isNot(c));
|
||||
const Object notARange = 'not a range';
|
||||
expect(a == notARange, isFalse); // type-mismatch branch
|
||||
expect(identical(a, a), isTrue);
|
||||
expect(a.toString(), 'Range(CellOffset(0, 0), CellOffset(1, 1))');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _dummyRange = _StubRange(CellOffset(0, 0), CellOffset(0, 0));
|
||||
|
||||
class _StubRange extends BufferRange {
|
||||
const _StubRange(super.begin, super.end);
|
||||
@override
|
||||
BufferRange get normalized => this;
|
||||
@override
|
||||
Iterable<BufferSegment> toSegments() => const [];
|
||||
@override
|
||||
bool contains(CellOffset position) => false;
|
||||
@override
|
||||
BufferRange merge(BufferRange range) => this;
|
||||
@override
|
||||
BufferRange extend(CellOffset position) => this;
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/// Pure-Dart tests for EscapeParser + EscapeEmitter.
|
||||
///
|
||||
/// Drives a recording EscapeHandler through every reachable dispatch
|
||||
/// path in the parser: SBC controls, ESC escapes, CSI sequences (with
|
||||
/// and without params, with and without prefixes), SGR styling
|
||||
/// (incl. 256-colour and 24-bit RGB), DEC private modes, OSC, window
|
||||
/// manipulation, and incomplete-sequence rollback.
|
||||
library;
|
||||
|
||||
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/mouse/mode.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _Call {
|
||||
const _Call(this.name, this.args);
|
||||
final String name;
|
||||
final List<Object?> args;
|
||||
@override
|
||||
String toString() => '$name(${args.join(", ")})';
|
||||
}
|
||||
|
||||
class _RecordingHandler implements EscapeHandler {
|
||||
final calls = <_Call>[];
|
||||
|
||||
void clear() => calls.clear();
|
||||
Iterable<_Call> named(String n) => calls.where((c) => c.name == n);
|
||||
_Call lastCallNamed(String n) => named(n).last;
|
||||
|
||||
@override
|
||||
void writeChar(int char) => calls.add(_Call("writeChar", [char]));
|
||||
@override
|
||||
void bell() => calls.add(_Call("bell", const []));
|
||||
@override
|
||||
void backspaceReturn() => calls.add(_Call("backspaceReturn", const []));
|
||||
@override
|
||||
void tab() => calls.add(_Call("tab", const []));
|
||||
@override
|
||||
void lineFeed() => calls.add(_Call("lineFeed", const []));
|
||||
@override
|
||||
void carriageReturn() => calls.add(_Call("carriageReturn", const []));
|
||||
@override
|
||||
void shiftOut() => calls.add(_Call("shiftOut", const []));
|
||||
@override
|
||||
void shiftIn() => calls.add(_Call("shiftIn", const []));
|
||||
@override
|
||||
void unknownSBC(int char) => calls.add(_Call("unknownSBC", [char]));
|
||||
@override
|
||||
void saveCursor() => calls.add(_Call("saveCursor", const []));
|
||||
@override
|
||||
void restoreCursor() => calls.add(_Call("restoreCursor", const []));
|
||||
@override
|
||||
void index() => calls.add(_Call("index", const []));
|
||||
@override
|
||||
void nextLine() => calls.add(_Call("nextLine", const []));
|
||||
@override
|
||||
void setTapStop() => calls.add(_Call("setTapStop", const []));
|
||||
@override
|
||||
void reverseIndex() => calls.add(_Call("reverseIndex", const []));
|
||||
@override
|
||||
void designateCharset(int charset, int name) => calls.add(_Call("designateCharset", [charset, name]));
|
||||
@override
|
||||
void unkownEscape(int char) => calls.add(_Call("unkownEscape", [char]));
|
||||
@override
|
||||
void repeatPreviousCharacter(int n) => calls.add(_Call("repeatPreviousCharacter", [n]));
|
||||
@override
|
||||
void setCursor(int x, int y) => calls.add(_Call("setCursor", [x, y]));
|
||||
@override
|
||||
void setCursorX(int x) => calls.add(_Call("setCursorX", [x]));
|
||||
@override
|
||||
void setCursorY(int y) => calls.add(_Call("setCursorY", [y]));
|
||||
@override
|
||||
void sendPrimaryDeviceAttributes() => calls.add(_Call("sendPrimaryDeviceAttributes", const []));
|
||||
@override
|
||||
void clearTabStopUnderCursor() => calls.add(_Call("clearTabStopUnderCursor", const []));
|
||||
@override
|
||||
void clearAllTabStops() => calls.add(_Call("clearAllTabStops", const []));
|
||||
@override
|
||||
void moveCursorX(int offset) => calls.add(_Call("moveCursorX", [offset]));
|
||||
@override
|
||||
void moveCursorY(int n) => calls.add(_Call("moveCursorY", [n]));
|
||||
@override
|
||||
void sendSecondaryDeviceAttributes() => calls.add(_Call("sendSecondaryDeviceAttributes", const []));
|
||||
@override
|
||||
void sendTertiaryDeviceAttributes() => calls.add(_Call("sendTertiaryDeviceAttributes", const []));
|
||||
@override
|
||||
void sendOperatingStatus() => calls.add(_Call("sendOperatingStatus", const []));
|
||||
@override
|
||||
void sendCursorPosition() => calls.add(_Call("sendCursorPosition", const []));
|
||||
@override
|
||||
void setMargins(int i, [int? bottom]) => calls.add(_Call("setMargins", [i, bottom]));
|
||||
@override
|
||||
void cursorNextLine(int amount) => calls.add(_Call("cursorNextLine", [amount]));
|
||||
@override
|
||||
void cursorPrecedingLine(int amount) => calls.add(_Call("cursorPrecedingLine", [amount]));
|
||||
@override
|
||||
void eraseDisplayBelow() => calls.add(_Call("eraseDisplayBelow", const []));
|
||||
@override
|
||||
void eraseDisplayAbove() => calls.add(_Call("eraseDisplayAbove", const []));
|
||||
@override
|
||||
void eraseDisplay() => calls.add(_Call("eraseDisplay", const []));
|
||||
@override
|
||||
void eraseScrollbackOnly() => calls.add(_Call("eraseScrollbackOnly", const []));
|
||||
@override
|
||||
void eraseLineRight() => calls.add(_Call("eraseLineRight", const []));
|
||||
@override
|
||||
void eraseLineLeft() => calls.add(_Call("eraseLineLeft", const []));
|
||||
@override
|
||||
void eraseLine() => calls.add(_Call("eraseLine", const []));
|
||||
@override
|
||||
void insertLines(int amount) => calls.add(_Call("insertLines", [amount]));
|
||||
@override
|
||||
void deleteLines(int amount) => calls.add(_Call("deleteLines", [amount]));
|
||||
@override
|
||||
void deleteChars(int amount) => calls.add(_Call("deleteChars", [amount]));
|
||||
@override
|
||||
void scrollUp(int amount) => calls.add(_Call("scrollUp", [amount]));
|
||||
@override
|
||||
void scrollDown(int amount) => calls.add(_Call("scrollDown", [amount]));
|
||||
@override
|
||||
void eraseChars(int amount) => calls.add(_Call("eraseChars", [amount]));
|
||||
@override
|
||||
void insertBlankChars(int amount) => calls.add(_Call("insertBlankChars", [amount]));
|
||||
@override
|
||||
void unknownCSI(int finalByte) => calls.add(_Call("unknownCSI", [finalByte]));
|
||||
@override
|
||||
void setInsertMode(bool enabled) => calls.add(_Call("setInsertMode", [enabled]));
|
||||
@override
|
||||
void setLineFeedMode(bool enabled) => calls.add(_Call("setLineFeedMode", [enabled]));
|
||||
@override
|
||||
void setUnknownMode(int mode, bool enabled) => calls.add(_Call("setUnknownMode", [mode, enabled]));
|
||||
@override
|
||||
void setCursorKeysMode(bool enabled) => calls.add(_Call("setCursorKeysMode", [enabled]));
|
||||
@override
|
||||
void setReverseDisplayMode(bool enabled) => calls.add(_Call("setReverseDisplayMode", [enabled]));
|
||||
@override
|
||||
void setOriginMode(bool enabled) => calls.add(_Call("setOriginMode", [enabled]));
|
||||
@override
|
||||
void setColumnMode(bool enabled) => calls.add(_Call("setColumnMode", [enabled]));
|
||||
@override
|
||||
void setAutoWrapMode(bool enabled) => calls.add(_Call("setAutoWrapMode", [enabled]));
|
||||
@override
|
||||
void setMouseMode(MouseMode mode) => calls.add(_Call("setMouseMode", [mode]));
|
||||
@override
|
||||
void setCursorBlinkMode(bool enabled) => calls.add(_Call("setCursorBlinkMode", [enabled]));
|
||||
@override
|
||||
void setCursorVisibleMode(bool enabled) => calls.add(_Call("setCursorVisibleMode", [enabled]));
|
||||
@override
|
||||
void useAltBuffer() => calls.add(_Call("useAltBuffer", const []));
|
||||
@override
|
||||
void useMainBuffer() => calls.add(_Call("useMainBuffer", const []));
|
||||
@override
|
||||
void clearAltBuffer() => calls.add(_Call("clearAltBuffer", const []));
|
||||
@override
|
||||
void setAppKeypadMode(bool enabled) => calls.add(_Call("setAppKeypadMode", [enabled]));
|
||||
@override
|
||||
void setReportFocusMode(bool enabled) => calls.add(_Call("setReportFocusMode", [enabled]));
|
||||
@override
|
||||
void setMouseReportMode(MouseReportMode mode) => calls.add(_Call("setMouseReportMode", [mode]));
|
||||
@override
|
||||
void setAltBufferMouseScrollMode(bool enabled) => calls.add(_Call("setAltBufferMouseScrollMode", [enabled]));
|
||||
@override
|
||||
void setBracketedPasteMode(bool enabled) => calls.add(_Call("setBracketedPasteMode", [enabled]));
|
||||
@override
|
||||
void setUnknownDecMode(int mode, bool enabled) => calls.add(_Call("setUnknownDecMode", [mode, enabled]));
|
||||
@override
|
||||
void resize(int cols, int rows) => calls.add(_Call("resize", [cols, rows]));
|
||||
@override
|
||||
void sendSize() => calls.add(_Call("sendSize", const []));
|
||||
@override
|
||||
void resetCursorStyle() => calls.add(_Call("resetCursorStyle", const []));
|
||||
@override
|
||||
void setCursorBold() => calls.add(_Call("setCursorBold", const []));
|
||||
@override
|
||||
void setCursorFaint() => calls.add(_Call("setCursorFaint", const []));
|
||||
@override
|
||||
void setCursorItalic() => calls.add(_Call("setCursorItalic", const []));
|
||||
@override
|
||||
void setCursorUnderline() => calls.add(_Call("setCursorUnderline", const []));
|
||||
@override
|
||||
void setCursorBlink() => calls.add(_Call("setCursorBlink", const []));
|
||||
@override
|
||||
void setCursorInverse() => calls.add(_Call("setCursorInverse", const []));
|
||||
@override
|
||||
void setCursorInvisible() => calls.add(_Call("setCursorInvisible", const []));
|
||||
@override
|
||||
void setCursorStrikethrough() => calls.add(_Call("setCursorStrikethrough", const []));
|
||||
@override
|
||||
void unsetCursorBold() => calls.add(_Call("unsetCursorBold", const []));
|
||||
@override
|
||||
void unsetCursorFaint() => calls.add(_Call("unsetCursorFaint", const []));
|
||||
@override
|
||||
void unsetCursorItalic() => calls.add(_Call("unsetCursorItalic", const []));
|
||||
@override
|
||||
void unsetCursorUnderline() => calls.add(_Call("unsetCursorUnderline", const []));
|
||||
@override
|
||||
void unsetCursorBlink() => calls.add(_Call("unsetCursorBlink", const []));
|
||||
@override
|
||||
void unsetCursorInverse() => calls.add(_Call("unsetCursorInverse", const []));
|
||||
@override
|
||||
void unsetCursorInvisible() => calls.add(_Call("unsetCursorInvisible", const []));
|
||||
@override
|
||||
void unsetCursorStrikethrough() => calls.add(_Call("unsetCursorStrikethrough", const []));
|
||||
@override
|
||||
void setForegroundColor16(int color) => calls.add(_Call("setForegroundColor16", [color]));
|
||||
@override
|
||||
void setForegroundColor256(int index) => calls.add(_Call("setForegroundColor256", [index]));
|
||||
@override
|
||||
void setForegroundColorRgb(int r, int g, int b) => calls.add(_Call("setForegroundColorRgb", [r, g, b]));
|
||||
@override
|
||||
void resetForeground() => calls.add(_Call("resetForeground", const []));
|
||||
@override
|
||||
void setBackgroundColor16(int color) => calls.add(_Call("setBackgroundColor16", [color]));
|
||||
@override
|
||||
void setBackgroundColor256(int index) => calls.add(_Call("setBackgroundColor256", [index]));
|
||||
@override
|
||||
void setBackgroundColorRgb(int r, int g, int b) => calls.add(_Call("setBackgroundColorRgb", [r, g, b]));
|
||||
@override
|
||||
void resetBackground() => calls.add(_Call("resetBackground", const []));
|
||||
@override
|
||||
void unsupportedStyle(int param) => calls.add(_Call("unsupportedStyle", [param]));
|
||||
@override
|
||||
void setTitle(String name) => calls.add(_Call("setTitle", [name]));
|
||||
@override
|
||||
void setIconName(String name) => calls.add(_Call("setIconName", [name]));
|
||||
@override
|
||||
void unknownOSC(String code, List<String> args) => calls.add(_Call("unknownOSC", [code, args]));
|
||||
}
|
||||
|
||||
({EscapeParser parser, _RecordingHandler h}) _newParser() {
|
||||
final h = _RecordingHandler();
|
||||
return (parser: EscapeParser(h), h: h);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('EscapeParser — plain characters', () {
|
||||
test('writes printable chars one-by-one to writeChar', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('Hi!');
|
||||
expect(
|
||||
f.h.calls.map((c) => c.name).toList(),
|
||||
['writeChar', 'writeChar', 'writeChar'],
|
||||
);
|
||||
expect(f.h.calls.map((c) => c.args.first).toList(), [
|
||||
'H'.codeUnitAt(0),
|
||||
'i'.codeUnitAt(0),
|
||||
'!'.codeUnitAt(0),
|
||||
]);
|
||||
});
|
||||
|
||||
test('chars beyond the SBC table go straight to writeChar', () {
|
||||
// Codepoint above the lookup table maxIndex falls through to the
|
||||
// explicit writeChar path.
|
||||
final f = _newParser();
|
||||
f.parser.write('中');
|
||||
expect(f.h.calls.single.name, 'writeChar');
|
||||
expect(f.h.calls.single.args.single, '中'.runes.first);
|
||||
});
|
||||
|
||||
test('low-byte char with no SBC entry calls unkownEscape', () {
|
||||
// 0x06 is below maxIndex but has no entry — parser routes it to
|
||||
// unkownEscape (note the typo in handler.dart).
|
||||
final f = _newParser();
|
||||
f.parser.write(String.fromCharCode(0x06));
|
||||
expect(f.h.calls.single.name, 'unkownEscape');
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — single-byte controls', () {
|
||||
test('BEL, BS, HT, LF, VT, FF, CR, SO, SI', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'bell',
|
||||
'backspaceReturn',
|
||||
'tab',
|
||||
'lineFeed', // LF
|
||||
'lineFeed', // VT shares LF
|
||||
'lineFeed', // FF shares LF
|
||||
'carriageReturn',
|
||||
'shiftOut',
|
||||
'shiftIn',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — ESC sequences', () {
|
||||
test('ESC 7 / ESC 8 → save / restore cursor', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b7\x1b8');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'saveCursor',
|
||||
'restoreCursor',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ESC D / E / H / M', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1bD\x1bE\x1bH\x1bM');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'index',
|
||||
'nextLine',
|
||||
'setTapStop',
|
||||
'reverseIndex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ESC ( / ESC ) designate G0 / G1 charset', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b(B\x1b)0');
|
||||
expect(f.h.calls.length, 2);
|
||||
expect(f.h.calls[0].name, 'designateCharset');
|
||||
expect(f.h.calls[0].args, [0, 'B'.codeUnitAt(0)]);
|
||||
expect(f.h.calls[1].name, 'designateCharset');
|
||||
expect(f.h.calls[1].args, [1, '0'.codeUnitAt(0)]);
|
||||
});
|
||||
|
||||
test('ESC = / ESC > toggle application keypad mode (DECKPAM/DECKPNM)', () {
|
||||
final f = _newParser();
|
||||
// ESC = enables app keypad mode, ESC > disables it.
|
||||
f.parser.write('\x1b=\x1b>');
|
||||
expect(
|
||||
f.h.named('setAppKeypadMode').map((c) => c.args.first).toList(),
|
||||
[true, false],
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown ESC byte → unkownEscape', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1bZ'); // Z has no handler
|
||||
expect(f.h.calls.single.name, 'unkownEscape');
|
||||
expect(f.h.calls.single.args.single, 'Z'.codeUnitAt(0));
|
||||
});
|
||||
|
||||
test('ESC alone is held back until the next chunk completes it', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b');
|
||||
expect(f.h.calls, isEmpty); // nothing dispatched yet
|
||||
f.parser.write('D'); // now ESC D = index
|
||||
expect(f.h.calls.single.name, 'index');
|
||||
});
|
||||
|
||||
test('ESC ( without a charset name is held back', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b(');
|
||||
expect(f.h.calls, isEmpty);
|
||||
f.parser.write('B');
|
||||
expect(f.h.calls.single.name, 'designateCharset');
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI cursor movement', () {
|
||||
test('A / B / C / D — default amount = 1, missing param uses default', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[A\x1b[B\x1b[C\x1b[D');
|
||||
expect(f.h.calls.map((c) => '${c.name}/${c.args}').toList(), [
|
||||
'moveCursorY/[-1]',
|
||||
'moveCursorY/[1]',
|
||||
'moveCursorX/[1]',
|
||||
'moveCursorX/[-1]',
|
||||
]);
|
||||
});
|
||||
|
||||
test('A / B / C / D — explicit amount + 0-as-1 fallback', () {
|
||||
// A negates (cursor up), B/D pass through, C also passes through.
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[3A'); // up 3 → moveCursorY(-3)
|
||||
expect(f.h.lastCallNamed('moveCursorY').args, [-3]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[0B'); // 0 falls back to 1 → moveCursorY(1)
|
||||
expect(f.h.lastCallNamed('moveCursorY').args, [1]);
|
||||
});
|
||||
|
||||
test('E / F — cursor next/preceding line', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[2E\x1b[F');
|
||||
expect(f.h.named('cursorNextLine').first.args, [2]);
|
||||
expect(f.h.named('cursorPrecedingLine').first.args, [1]);
|
||||
});
|
||||
|
||||
test('G — cursor horizontal absolute (1-based to 0-based)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[5G');
|
||||
expect(f.h.lastCallNamed('setCursorX').args, [4]); // 5-1
|
||||
});
|
||||
|
||||
test('H / f — cursor position with both row and column', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[3;7H\x1b[2;4f');
|
||||
expect(f.h.named('setCursor').first.args, [6, 2]); // (col-1, row-1)
|
||||
expect(f.h.named('setCursor').last.args, [3, 1]);
|
||||
});
|
||||
|
||||
test('H — no params resets to (0, 0)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[H');
|
||||
expect(f.h.lastCallNamed('setCursor').args, [0, 0]);
|
||||
});
|
||||
|
||||
test('d — line position absolute (1-based)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[3d');
|
||||
expect(f.h.lastCallNamed('setCursorY').args, [2]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[d'); // default = 1 → 0-based 0
|
||||
expect(f.h.lastCallNamed('setCursorY').args, [0]);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI erase / scroll / lines / chars', () {
|
||||
test('J 0/1/2/3 → erase display below/above/all/scrollback', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[J\x1b[1J\x1b[2J\x1b[3J');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'eraseDisplayBelow',
|
||||
'eraseDisplayAbove',
|
||||
'eraseDisplay',
|
||||
'eraseScrollbackOnly',
|
||||
]);
|
||||
});
|
||||
|
||||
test('K 0/1/2 → erase line right / left / all', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[K\x1b[1K\x1b[2K');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), ['eraseLineRight', 'eraseLineLeft', 'eraseLine']);
|
||||
});
|
||||
|
||||
test('L / M — insert / delete lines', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[L\x1b[3M');
|
||||
expect(f.h.named('insertLines').first.args, [1]);
|
||||
expect(f.h.named('deleteLines').first.args, [3]);
|
||||
});
|
||||
|
||||
test('P — delete chars; @ — insert blanks; X — erase chars', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[2P\x1b[3@\x1b[4X');
|
||||
expect(f.h.named('deleteChars').first.args, [2]);
|
||||
expect(f.h.named('insertBlankChars').first.args, [3]);
|
||||
expect(f.h.named('eraseChars').first.args, [4]);
|
||||
});
|
||||
|
||||
test('S / T — scroll up / down', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[2S\x1b[3T');
|
||||
expect(f.h.named('scrollUp').first.args, [2]);
|
||||
expect(f.h.named('scrollDown').first.args, [3]);
|
||||
});
|
||||
|
||||
test('b — repeat previous character', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[3b');
|
||||
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [3]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[0b'); // 0 falls back to 1
|
||||
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [1]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[b'); // empty defaults to 1
|
||||
expect(f.h.lastCallNamed('repeatPreviousCharacter').args, [1]);
|
||||
});
|
||||
|
||||
test('g — clear tab stop (default 0 → under cursor; non-zero → all)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[g\x1b[3g');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), ['clearTabStopUnderCursor', 'clearAllTabStops']);
|
||||
});
|
||||
|
||||
test('r — set top/bottom margins; default top, no bottom', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[3;10r');
|
||||
expect(f.h.named('setMargins').last.args, [2, 9]); // (top-1, bottom-1)
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[r'); // no params → top=1, bottom=null
|
||||
expect(f.h.named('setMargins').last.args, [0, null]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[1;2;3r'); // too many params → no-op
|
||||
expect(f.h.named('setMargins'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI device attributes / status reports', () {
|
||||
test('c / >c / =c — primary, secondary, tertiary device attributes', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[c\x1b[>c\x1b[=c');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'sendPrimaryDeviceAttributes',
|
||||
'sendSecondaryDeviceAttributes',
|
||||
'sendTertiaryDeviceAttributes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('n 5 → operating status; n 6 → cursor position', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[5n\x1b[6n');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), ['sendOperatingStatus', 'sendCursorPosition']);
|
||||
});
|
||||
|
||||
test('n with no params is a no-op (defensive)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[n');
|
||||
expect(f.h.calls, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI window manipulation', () {
|
||||
test('CSI 8 ; rows ; cols t → resize(cols, rows)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[8;24;80t');
|
||||
expect(f.h.lastCallNamed('resize').args, [80, 24]);
|
||||
});
|
||||
|
||||
test('CSI 18 t → sendSize', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[18t');
|
||||
expect(f.h.calls.single.name, 'sendSize');
|
||||
});
|
||||
|
||||
test('CSI ignored window ops (no params, no-scope codes) emit nothing', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[t\x1b[1t\x1b[5t\x1b[9t\x1b[19t\x1b[22t\x1b[99t');
|
||||
expect(f.h.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('CSI 8 t with wrong number of params is a no-op', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[8t');
|
||||
expect(f.h.calls, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI mode set / reset', () {
|
||||
test('CSI [4]h / l → setInsertMode true/false', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[4h\x1b[4l');
|
||||
expect(f.h.named('setInsertMode').map((c) => c.args.first).toList(), [true, false]);
|
||||
});
|
||||
|
||||
test('CSI [20]h → setLineFeedMode', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[20h');
|
||||
expect(f.h.named('setLineFeedMode').first.args, [true]);
|
||||
});
|
||||
|
||||
test('CSI unknown mode → setUnknownMode(mode, enabled)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[99h\x1b[99l');
|
||||
expect(f.h.named('setUnknownMode').map((c) => c.args).toList(), [
|
||||
[99, true],
|
||||
[99, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('CSI ?1 / ?7 / ?25 / ?2004 — DEC modes route correctly', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?1h\x1b[?7l\x1b[?25h\x1b[?2004l');
|
||||
expect(f.h.named('setCursorKeysMode').first.args, [true]);
|
||||
expect(f.h.named('setAutoWrapMode').first.args, [false]);
|
||||
expect(f.h.named('setCursorVisibleMode').first.args, [true]);
|
||||
expect(f.h.named('setBracketedPasteMode').first.args, [false]);
|
||||
});
|
||||
|
||||
test('CSI ?47 — alt buffer toggle', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?47h\x1b[?47l');
|
||||
expect(f.h.named('useAltBuffer').length, 1);
|
||||
expect(f.h.named('useMainBuffer').length, 1);
|
||||
});
|
||||
|
||||
test('CSI ?1047 — alt buffer + clear on disable', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?1047h\x1b[?1047l');
|
||||
expect(f.h.named('useAltBuffer').length, 1);
|
||||
expect(f.h.named('clearAltBuffer').length, 1);
|
||||
expect(f.h.named('useMainBuffer').length, 1);
|
||||
});
|
||||
|
||||
test('CSI ?1048 — save / restore cursor', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?1048h\x1b[?1048l');
|
||||
expect(f.h.named('saveCursor').length, 1);
|
||||
expect(f.h.named('restoreCursor').length, 1);
|
||||
});
|
||||
|
||||
test('CSI ?1049 — save+clear+alt on enable; main on disable', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?1049h\x1b[?1049l');
|
||||
expect(f.h.named('saveCursor').length, 1);
|
||||
expect(f.h.named('clearAltBuffer').length, 1);
|
||||
expect(f.h.named('useAltBuffer').length, 1);
|
||||
expect(f.h.named('useMainBuffer').length, 1);
|
||||
});
|
||||
|
||||
test('CSI ?9 / ?1000 / ?1002 / ?1003 — mouse modes route to setMouseMode', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?9h\x1b[?1000h\x1b[?1002h\x1b[?1003h');
|
||||
final modes = f.h.named('setMouseMode').map((c) => c.args.first).toList();
|
||||
expect(modes, [
|
||||
MouseMode.clickOnly,
|
||||
MouseMode.upDownScroll,
|
||||
MouseMode.upDownScrollDrag,
|
||||
MouseMode.upDownScrollMove,
|
||||
]);
|
||||
});
|
||||
|
||||
test('CSI ?9 / ?1000 disabled → setMouseMode(none)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?9l\x1b[?1000l');
|
||||
final modes = f.h.named('setMouseMode').map((c) => c.args.first).toList();
|
||||
expect(modes, [MouseMode.none, MouseMode.none]);
|
||||
});
|
||||
|
||||
test('CSI ?1005 / ?1006 / ?1015 — mouse report modes', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?1005h\x1b[?1006h\x1b[?1015h\x1b[?1006l');
|
||||
final modes = f.h.named('setMouseReportMode').map((c) => c.args.first).toList();
|
||||
expect(modes, [
|
||||
MouseReportMode.utf,
|
||||
MouseReportMode.sgr,
|
||||
MouseReportMode.urxvt,
|
||||
MouseReportMode.normal,
|
||||
]);
|
||||
});
|
||||
|
||||
test('CSI ?3 / ?5 / ?6 / ?12 / ?66 / ?1004 / ?1007 — column/reverse/origin/blink/keypad/focus/altScroll', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?3h\x1b[?5l\x1b[?6h\x1b[?12h\x1b[?66h\x1b[?1004h\x1b[?1007h');
|
||||
expect(f.h.named('setColumnMode').first.args, [true]);
|
||||
expect(f.h.named('setReverseDisplayMode').first.args, [false]);
|
||||
expect(f.h.named('setOriginMode').first.args, [true]);
|
||||
expect(f.h.named('setCursorBlinkMode').first.args, [true]);
|
||||
expect(f.h.named('setAppKeypadMode').first.args, [true]);
|
||||
expect(f.h.named('setReportFocusMode').first.args, [true]);
|
||||
expect(f.h.named('setAltBufferMouseScrollMode').first.args, [true]);
|
||||
});
|
||||
|
||||
test('CSI ?9999 — unknown DEC mode falls through to setUnknownDecMode', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[?9999h');
|
||||
expect(f.h.named('setUnknownDecMode').first.args, [9999, true]);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI SGR styling', () {
|
||||
test('empty params → resetCursorStyle', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[m');
|
||||
expect(f.h.calls.single.name, 'resetCursorStyle');
|
||||
});
|
||||
|
||||
test('single attribute params 0..9 / 21..29 — set + unset', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[0;1;2;3;4;5;7;8;9m');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'resetCursorStyle',
|
||||
'setCursorBold',
|
||||
'setCursorFaint',
|
||||
'setCursorItalic',
|
||||
'setCursorUnderline',
|
||||
'setCursorBlink',
|
||||
'setCursorInverse',
|
||||
'setCursorInvisible',
|
||||
'setCursorStrikethrough',
|
||||
]);
|
||||
f.h.clear();
|
||||
f.parser.write('\x1b[21;22;23;24;25;27;28;29m');
|
||||
expect(f.h.calls.map((c) => c.name).toList(), [
|
||||
'unsetCursorBold',
|
||||
'unsetCursorFaint',
|
||||
'unsetCursorItalic',
|
||||
'unsetCursorUnderline',
|
||||
'unsetCursorBlink',
|
||||
'unsetCursorInverse',
|
||||
'unsetCursorInvisible',
|
||||
'unsetCursorStrikethrough',
|
||||
]);
|
||||
});
|
||||
|
||||
test('foreground colour 16 — params 30..37 + 90..97', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[30;31;32;33;34;35;36;37;90;91;92;93;94;95;96;97m');
|
||||
// 16 calls to setForegroundColor16 with successive NamedColor int values.
|
||||
final args = f.h.named('setForegroundColor16').map((c) => c.args.first).toList();
|
||||
// NamedColor.black==0 .. white==7, brightBlack==8 .. brightWhite==15.
|
||||
expect(args, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
});
|
||||
|
||||
test('background colour 16 — params 40..47 + 100..107', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[40;41;42;43;44;45;46;47;100;101;102;103;104;105;106;107m');
|
||||
final args = f.h.named('setBackgroundColor16').map((c) => c.args.first).toList();
|
||||
expect(args, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
});
|
||||
|
||||
test('38;5;n / 48;5;n — 256-colour palette', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[38;5;200m\x1b[48;5;42m');
|
||||
expect(f.h.named('setForegroundColor256').first.args, [200]);
|
||||
expect(f.h.named('setBackgroundColor256').first.args, [42]);
|
||||
});
|
||||
|
||||
test('38;2;r;g;b / 48;2;r;g;b — 24-bit RGB', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[38;2;10;20;30m\x1b[48;2;100;150;200m');
|
||||
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
|
||||
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
|
||||
});
|
||||
|
||||
test('39 / 49 — reset foreground / background', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[39;49m');
|
||||
expect(f.h.named('resetForeground').length, 1);
|
||||
expect(f.h.named('resetBackground').length, 1);
|
||||
});
|
||||
|
||||
test('unknown SGR param → unsupportedStyle', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[123m');
|
||||
expect(f.h.named('unsupportedStyle').first.args, [123]);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — OSC sequences', () {
|
||||
test('OSC 0 ; title BEL → setTitle + setIconName', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]0;hello\x07');
|
||||
expect(f.h.named('setTitle').first.args, ['hello']);
|
||||
expect(f.h.named('setIconName').first.args, ['hello']);
|
||||
});
|
||||
|
||||
test('OSC 1 ; iconname BEL → setIconName only', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]1;icon\x07');
|
||||
expect(f.h.named('setIconName').first.args, ['icon']);
|
||||
expect(f.h.named('setTitle'), isEmpty);
|
||||
});
|
||||
|
||||
test('OSC 2 ; title BEL → setTitle only', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]2;title\x07');
|
||||
expect(f.h.named('setTitle').first.args, ['title']);
|
||||
expect(f.h.named('setIconName'), isEmpty);
|
||||
});
|
||||
|
||||
test('OSC terminated by ST (ESC \\) instead of BEL', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]0;via-st\x1b\\');
|
||||
expect(f.h.named('setTitle').first.args, ['via-st']);
|
||||
});
|
||||
|
||||
test('OSC unknown ps → unknownOSC', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]99;arg1;arg2\x07');
|
||||
expect(f.h.named('unknownOSC').first.args[0], '99');
|
||||
expect(f.h.named('unknownOSC').first.args[1], ['arg1', 'arg2']);
|
||||
});
|
||||
|
||||
test('OSC with only a terminator dispatches unknownOSC with empty params', () {
|
||||
// _consumeOsc records the empty string before BEL, so _osc = [""];
|
||||
// length is 1 (not 0), so the empty-fast-path doesn't fire and the
|
||||
// sequence falls through to unknownOSC.
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]\x07');
|
||||
expect(f.h.named('unknownOSC').first.args[0], '');
|
||||
expect(f.h.named('unknownOSC').first.args[1], <String>[]);
|
||||
});
|
||||
|
||||
test('OSC incomplete sequence is held back until terminator arrives', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b]0;par');
|
||||
expect(f.h.calls, isEmpty);
|
||||
f.parser.write('tial\x07');
|
||||
expect(f.h.named('setTitle').first.args, ['partial']);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — CSI unknown final byte', () {
|
||||
test('routes to unknownCSI(finalByte)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[~'); // ~ has no entry in _csiHandlers
|
||||
expect(f.h.named('unknownCSI').first.args, ['~'.codeUnitAt(0)]);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — token bookkeeping', () {
|
||||
test('tokenBegin / tokenEnd advance with consumed bytes', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('AB\x1b[H');
|
||||
// Last token was the CSI H sequence. tokenBegin sits at the start
|
||||
// of the ESC; tokenEnd advances to one past the last consumed byte.
|
||||
expect(f.parser.tokenEnd, greaterThan(f.parser.tokenBegin));
|
||||
expect(f.parser.tokenEnd, 5); // total bytes consumed
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeEmitter', () {
|
||||
const e = EscapeEmitter();
|
||||
|
||||
test('primary device attributes', () {
|
||||
expect(e.primaryDeviceAttributes(), '\x1b[?1;2c');
|
||||
});
|
||||
|
||||
test('secondary device attributes', () {
|
||||
expect(e.secondaryDeviceAttributes(), '\x1b[>0;0;0c');
|
||||
});
|
||||
|
||||
test('tertiary device attributes', () {
|
||||
expect(e.tertiaryDeviceAttributes(), '\x1bP!|00000000\x1b\\');
|
||||
});
|
||||
|
||||
test('operating status', () {
|
||||
expect(e.operatingStatus(), '\x1b[0n');
|
||||
});
|
||||
|
||||
test('cursor position', () {
|
||||
expect(e.cursorPosition(7, 3), '\x1b[3;7R');
|
||||
});
|
||||
|
||||
test('bracketed paste wraps the text', () {
|
||||
expect(e.bracketedPaste('abc'), '\x1b[200~abc\x1b[201~');
|
||||
});
|
||||
|
||||
test('size emits CSI 8 ; rows ; cols t', () {
|
||||
expect(e.size(24, 80), '\x1b[8;24;80t');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
/// Pure-Dart tests for `lib/src/terminal/src/core/input/`.
|
||||
///
|
||||
/// Covers the keytab tokenizer + parser, the unescape helper, the
|
||||
/// KeytabRecord toString shapes, the `Keytab.find` modifier-matching
|
||||
/// rules, and the four `TerminalInputHandler` implementations.
|
||||
library;
|
||||
|
||||
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/input/keytab/keytab.dart';
|
||||
import 'package:clide/src/terminal/src/core/input/keytab/keytab_escape.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';
|
||||
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
|
||||
import 'package:clide/src/terminal/src/core/cursor.dart';
|
||||
import 'package:clide/src/terminal/src/core/platform.dart';
|
||||
import 'package:clide/src/terminal/src/core/state.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _State implements TerminalState {
|
||||
_State({
|
||||
this.lineFeedMode = false,
|
||||
this.appKeypadMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
bool lineFeedMode;
|
||||
@override
|
||||
bool appKeypadMode;
|
||||
|
||||
@override
|
||||
int get viewWidth => 80;
|
||||
@override
|
||||
int get viewHeight => 24;
|
||||
@override
|
||||
CursorStyle get cursor => CursorStyle();
|
||||
@override
|
||||
bool get reflowEnabled => false;
|
||||
@override
|
||||
bool get insertMode => false;
|
||||
@override
|
||||
bool get cursorKeysMode => false;
|
||||
@override
|
||||
bool get reverseDisplayMode => false;
|
||||
@override
|
||||
bool get originMode => false;
|
||||
@override
|
||||
bool get autoWrapMode => true;
|
||||
@override
|
||||
MouseMode get mouseMode => MouseMode.none;
|
||||
@override
|
||||
MouseReportMode get mouseReportMode => MouseReportMode.normal;
|
||||
@override
|
||||
bool get cursorBlinkMode => true;
|
||||
@override
|
||||
bool get cursorVisibleMode => true;
|
||||
@override
|
||||
bool get reportFocusMode => false;
|
||||
@override
|
||||
bool get altBufferMouseScrollMode => false;
|
||||
@override
|
||||
bool get bracketedPasteMode => false;
|
||||
}
|
||||
|
||||
TerminalKeyboardEvent _evt(
|
||||
TerminalKey key, {
|
||||
bool ctrl = false,
|
||||
bool alt = false,
|
||||
bool shift = false,
|
||||
bool altBuffer = false,
|
||||
bool lineFeedMode = false,
|
||||
bool appKeypadMode = false,
|
||||
TerminalTargetPlatform platform = TerminalTargetPlatform.linux,
|
||||
}) {
|
||||
return TerminalKeyboardEvent(
|
||||
key: key,
|
||||
shift: shift,
|
||||
ctrl: ctrl,
|
||||
alt: alt,
|
||||
state: _State(
|
||||
lineFeedMode: lineFeedMode,
|
||||
appKeypadMode: appKeypadMode,
|
||||
),
|
||||
altBuffer: altBuffer,
|
||||
platform: platform,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('keytabUnescape', () {
|
||||
test(r'\E maps to ESC (0x1b)', () {
|
||||
expect(keytabUnescape(r'\E'), '\x1b');
|
||||
expect(keytabUnescape(r'\E[A'), '\x1b[A');
|
||||
});
|
||||
|
||||
test('classic backslash escapes round-trip', () {
|
||||
expect(keytabUnescape(r'\\'), r'\');
|
||||
expect(keytabUnescape(r'\"'), '"');
|
||||
expect(keytabUnescape(r'\t'), '\t');
|
||||
expect(keytabUnescape(r'\r'), '\r');
|
||||
expect(keytabUnescape(r'\n'), '\n');
|
||||
expect(keytabUnescape(r'\b'), '\b');
|
||||
});
|
||||
|
||||
test(r'\xHH parses two hex digits to a single char', () {
|
||||
expect(keytabUnescape(r'\x00'), '\x00');
|
||||
expect(keytabUnescape(r'\x7f'), '\x7f');
|
||||
expect(keytabUnescape(r'\xFF'), 'ÿ');
|
||||
});
|
||||
|
||||
test(r'leaves unrecognised text alone', () {
|
||||
expect(keytabUnescape('hello'), 'hello');
|
||||
expect(keytabUnescape(''), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('LineReader', () {
|
||||
test('peek/take advance + done detection', () {
|
||||
final r = LineReader('abc');
|
||||
expect(r.peek(), 'a');
|
||||
expect(r.take(), 'a');
|
||||
expect(r.peek(2), 'bc');
|
||||
expect(r.take(2), 'bc');
|
||||
expect(r.done, isTrue);
|
||||
expect(r.peek(), isNull);
|
||||
expect(r.take(), isNull);
|
||||
});
|
||||
|
||||
test('peek clamps when count exceeds remaining length', () {
|
||||
final r = LineReader('ab');
|
||||
expect(r.peek(99), 'ab');
|
||||
});
|
||||
|
||||
test('skipWhitespace eats spaces and tabs but stops on other chars', () {
|
||||
final r = LineReader(' \t hello');
|
||||
r.skipWhitespace();
|
||||
expect(r.peek(5), 'hello');
|
||||
});
|
||||
|
||||
test('readString takes alphanumeric / underscore until break', () {
|
||||
final r = LineReader('abc_123 next');
|
||||
expect(r.readString(), 'abc_123');
|
||||
// Position should now be at the space.
|
||||
expect(r.peek(), ' ');
|
||||
});
|
||||
|
||||
test('readUntil takes everything up to the pattern (exclusive by default)', () {
|
||||
final r = LineReader('foo"bar');
|
||||
expect(r.readUntil('"'), 'foo');
|
||||
expect(r.peek(), '"');
|
||||
});
|
||||
|
||||
test('readUntil with inclusive=true consumes the boundary char', () {
|
||||
final r = LineReader('foo"bar');
|
||||
expect(r.readUntil('"', inclusive: true), 'foo"');
|
||||
expect(r.peek(), 'b');
|
||||
});
|
||||
});
|
||||
|
||||
group('tokenize', () {
|
||||
test('parses a keyboard-name line', () {
|
||||
final tokens = tokenize('keyboard "Default"').toList();
|
||||
expect(tokens.map((t) => t.type).toList(), [
|
||||
KeytabTokenType.keyboard,
|
||||
KeytabTokenType.input,
|
||||
]);
|
||||
expect(tokens.last.value, 'Default');
|
||||
});
|
||||
|
||||
test('parses a key-define line with modes and string action', () {
|
||||
final tokens = tokenize('key Up -Shift+Ansi : "\\EOA"').toList();
|
||||
expect(tokens.map((t) => t.type).toList(), [
|
||||
KeytabTokenType.keyDefine,
|
||||
KeytabTokenType.keyName,
|
||||
KeytabTokenType.modeStatus,
|
||||
KeytabTokenType.mode,
|
||||
KeytabTokenType.modeStatus,
|
||||
KeytabTokenType.mode,
|
||||
KeytabTokenType.colon,
|
||||
KeytabTokenType.input,
|
||||
]);
|
||||
expect(tokens[1].value, 'Up');
|
||||
expect(tokens.last.value, r'\EOA');
|
||||
});
|
||||
|
||||
test('parses a shortcut action (no quotes)', () {
|
||||
final tokens = tokenize('key Up +Shift : scrollLineUp').toList();
|
||||
expect(tokens.last.type, KeytabTokenType.shortcut);
|
||||
expect(tokens.last.value, 'scrollLineUp');
|
||||
});
|
||||
|
||||
test('skips comments and blank lines', () {
|
||||
final source = '''
|
||||
# top comment
|
||||
keyboard "X"
|
||||
|
||||
# inner
|
||||
key Tab : "\\t" # trailing comment
|
||||
''';
|
||||
final tokens = tokenize(source).toList();
|
||||
// Trailing-comment stripping leaves no broken tokens.
|
||||
expect(
|
||||
tokens.map((t) => t.type).toList(),
|
||||
contains(KeytabTokenType.colon),
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenize throws TokenizeError on a malformed key line missing colon', () {
|
||||
expect(
|
||||
() => tokenize('key Tab "\\t"').toList(),
|
||||
throwsA(isA<TokenizeError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenize throws TokenizeError on unterminated keyboard line', () {
|
||||
expect(
|
||||
() => tokenize('keyboard X').toList(),
|
||||
throwsA(isA<TokenizeError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('KeytabToken toString reflects type + value', () {
|
||||
final tok = KeytabToken(KeytabTokenType.input, 'hello');
|
||||
expect(tok.toString(), 'KeytabTokenType.input<hello>');
|
||||
});
|
||||
});
|
||||
|
||||
group('KeytabParser', () {
|
||||
test('parses a keyboard name + a key-define line into a Keytab', () {
|
||||
const src = '''
|
||||
keyboard "Test"
|
||||
key Up -Shift+Ansi : "\\EOA"
|
||||
''';
|
||||
final t = Keytab.parse(src);
|
||||
expect(t.name, 'Test');
|
||||
expect(t.records, hasLength(1));
|
||||
expect(t.records.first.qtKeyName, 'Up');
|
||||
expect(t.records.first.shift, isFalse);
|
||||
expect(t.records.first.ansi, isTrue);
|
||||
});
|
||||
|
||||
test('every supported mode label maps to its KeytabRecord field', () {
|
||||
const src = '''
|
||||
keyboard "Modes"
|
||||
key Up +Alt+Control+Shift+AnyMod+Ansi+AppScreen+KeyPad+AppCuKeys+AppKeyPad+NewLine+Mac : "x"
|
||||
''';
|
||||
final t = Keytab.parse(src);
|
||||
final r = t.records.single;
|
||||
expect(r.alt, isTrue);
|
||||
expect(r.ctrl, isTrue);
|
||||
expect(r.shift, isTrue);
|
||||
expect(r.anyModifier, isTrue);
|
||||
expect(r.ansi, isTrue);
|
||||
expect(r.appScreen, isTrue);
|
||||
expect(r.keyPad, isTrue);
|
||||
expect(r.appCursorKeys, isTrue);
|
||||
expect(r.appKeyPad, isTrue);
|
||||
expect(r.newLine, isTrue);
|
||||
expect(r.macos, isTrue);
|
||||
});
|
||||
|
||||
test('parser throws ParseError on an unknown qt key name', () {
|
||||
const src = '''
|
||||
keyboard "X"
|
||||
key NotARealKey : "x"
|
||||
''';
|
||||
expect(() => Keytab.parse(src), throwsA(isA<ParseError>()));
|
||||
});
|
||||
|
||||
test('parser throws ParseError on an unknown mode label', () {
|
||||
const src = '''
|
||||
keyboard "X"
|
||||
key Up +Bogus : "x"
|
||||
''';
|
||||
expect(() => Keytab.parse(src), throwsA(isA<ParseError>()));
|
||||
});
|
||||
|
||||
test('TokensReader peek/take semantics', () {
|
||||
final a = KeytabToken(KeytabTokenType.colon, ':');
|
||||
final b = KeytabToken(KeytabTokenType.colon, ':');
|
||||
final r = TokensReader([a, b]);
|
||||
expect(r.peek(), a);
|
||||
expect(r.take(), a);
|
||||
expect(r.peek(), b);
|
||||
expect(r.take(), b);
|
||||
expect(r.done, isTrue);
|
||||
expect(r.peek(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('KeytabRecord / KeytabAction toString', () {
|
||||
test('action toString quotes input and bare-prints shortcut', () {
|
||||
expect(
|
||||
KeytabAction(KeytabActionType.input, r'\E[A').toString(),
|
||||
'"\\E[A"',
|
||||
);
|
||||
expect(
|
||||
KeytabAction(KeytabActionType.shortcut, 'scrollUp').toString(),
|
||||
'scrollUp',
|
||||
);
|
||||
});
|
||||
|
||||
test('record toString writes +Mode for true and -Mode for false flags', () {
|
||||
final r = KeytabRecord(
|
||||
qtKeyName: 'Up',
|
||||
key: TerminalKey.arrowUp,
|
||||
action: KeytabAction(KeytabActionType.shortcut, 'scrollLineUp'),
|
||||
alt: true,
|
||||
ctrl: false,
|
||||
shift: null,
|
||||
anyModifier: null,
|
||||
ansi: true,
|
||||
appScreen: false,
|
||||
keyPad: null,
|
||||
appCursorKeys: null,
|
||||
appKeyPad: null,
|
||||
newLine: null,
|
||||
macos: null,
|
||||
);
|
||||
final s = r.toString();
|
||||
expect(s, contains('+Alt'));
|
||||
expect(s, contains('-Control'));
|
||||
expect(s, contains('+Ansi'));
|
||||
expect(s, contains('-AppScreen'));
|
||||
expect(s, isNot(contains('Shift')));
|
||||
expect(s, endsWith(': scrollLineUp'));
|
||||
});
|
||||
|
||||
test('Keytab toString lists name + records', () {
|
||||
const src = '''
|
||||
keyboard "Listed"
|
||||
key Up +Shift : scrollLineUp
|
||||
''';
|
||||
final t = Keytab.parse(src);
|
||||
final s = t.toString();
|
||||
expect(s, contains('keyboard "Listed"'));
|
||||
expect(s, contains('Up'));
|
||||
expect(s, contains('scrollLineUp'));
|
||||
});
|
||||
|
||||
test('action unescapedValue returns the raw value for shortcut actions', () {
|
||||
// Input actions go through keytabUnescape; shortcut actions don't.
|
||||
expect(
|
||||
KeytabAction(KeytabActionType.shortcut, r'\Ehello').unescapedValue(),
|
||||
r'\Ehello',
|
||||
);
|
||||
});
|
||||
|
||||
test('record toString covers every supported mode flag when set', () {
|
||||
// Sets every nullable flag so each `if (foo != null)` branch fires.
|
||||
final r = KeytabRecord(
|
||||
qtKeyName: 'Up',
|
||||
key: TerminalKey.arrowUp,
|
||||
action: KeytabAction(KeytabActionType.input, 'x'),
|
||||
alt: true,
|
||||
ctrl: true,
|
||||
shift: false,
|
||||
anyModifier: true,
|
||||
ansi: true,
|
||||
appScreen: false,
|
||||
keyPad: true,
|
||||
appCursorKeys: false,
|
||||
appKeyPad: true,
|
||||
newLine: false,
|
||||
macos: true,
|
||||
);
|
||||
final s = r.toString();
|
||||
expect(s, contains('+Alt'));
|
||||
expect(s, contains('+Control'));
|
||||
expect(s, contains('-Shift'));
|
||||
expect(s, contains('+AnyMod'));
|
||||
expect(s, contains('+Ansi'));
|
||||
expect(s, contains('-AppScreen'));
|
||||
expect(s, contains('+KeyPad'));
|
||||
expect(s, contains('-AppCuKeys'));
|
||||
expect(s, contains('+AppKeyPad'));
|
||||
expect(s, contains('-NewLine'));
|
||||
expect(s, contains('+Mac'));
|
||||
expect(s, endsWith(': "x"'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KeytabParser — defensive error paths through hand-crafted tokens', () {
|
||||
test('addTokens throws ParseError on a stray non-keyboard / non-keyDefine token', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.colon, ':'),
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseName throws when the second token is not an input token', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyboard, 'keyboard'),
|
||||
KeytabToken(KeytabTokenType.keyName, 'Up'),
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseKeyDefine throws when the second token is not a keyName', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyDefine, 'key'),
|
||||
KeytabToken(KeytabTokenType.colon, ':'),
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseKeyDefine throws on an unrecognised modeStatus value', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyDefine, 'key'),
|
||||
KeytabToken(KeytabTokenType.keyName, 'Up'),
|
||||
KeytabToken(KeytabTokenType.modeStatus, 'X'), // not '+' / '-'
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseKeyDefine throws when the token after modeStatus is not a mode', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyDefine, 'key'),
|
||||
KeytabToken(KeytabTokenType.keyName, 'Up'),
|
||||
KeytabToken(KeytabTokenType.modeStatus, '+'),
|
||||
KeytabToken(KeytabTokenType.colon, ':'), // not a mode token
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseKeyDefine throws when the colon is missing', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyDefine, 'key'),
|
||||
KeytabToken(KeytabTokenType.keyName, 'Up'),
|
||||
KeytabToken(KeytabTokenType.input, 'x'), // should be colon here
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('_parseKeyDefine throws when the action token is neither input nor shortcut', () {
|
||||
final parser = KeytabParser();
|
||||
expect(
|
||||
() => parser.addTokens([
|
||||
KeytabToken(KeytabTokenType.keyDefine, 'key'),
|
||||
KeytabToken(KeytabTokenType.keyName, 'Up'),
|
||||
KeytabToken(KeytabTokenType.colon, ':'),
|
||||
KeytabToken(KeytabTokenType.mode, 'Alt'), // not a valid action
|
||||
]),
|
||||
throwsA(isA<ParseError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Keytab.find — modifier matching', () {
|
||||
Keytab build(String src) => Keytab.parse('keyboard "X"\n$src\n');
|
||||
|
||||
test('exact match: -Shift on a record with shift=null and no anyModifier', () {
|
||||
final t = build('key Up -Shift : "\\E[A"');
|
||||
expect(
|
||||
t.find(TerminalKey.arrowUp, shift: false)?.action.value,
|
||||
r'\E[A',
|
||||
);
|
||||
expect(t.find(TerminalKey.arrowUp, shift: true), isNull);
|
||||
});
|
||||
|
||||
test('+AnyMod requires at least one modifier; rejects no-modifier press', () {
|
||||
final t = build('key Up +AnyMod : "\\E[A"');
|
||||
expect(t.find(TerminalKey.arrowUp), isNull);
|
||||
expect(t.find(TerminalKey.arrowUp, ctrl: true), isNotNull);
|
||||
});
|
||||
|
||||
test('-AnyMod requires zero modifiers; rejects any-modifier press', () {
|
||||
final t = build('key Up -AnyMod : "\\E[A"');
|
||||
expect(t.find(TerminalKey.arrowUp), isNotNull);
|
||||
expect(t.find(TerminalKey.arrowUp, alt: true), isNull);
|
||||
});
|
||||
|
||||
test('mode flags filter records (newLine + appKeyPad + appScreen + macos)', () {
|
||||
final t = build('''
|
||||
key Up +NewLine+AppKeyPad+AppScreen+Mac : "match"
|
||||
key Up : "fallback"
|
||||
''');
|
||||
// Wrong newLine mode falls through to fallback.
|
||||
expect(
|
||||
t.find(TerminalKey.arrowUp, newLineMode: false, appKeyPad: true, appScreen: true, macos: true)?.action.value,
|
||||
'fallback',
|
||||
);
|
||||
// All matching → primary record.
|
||||
expect(
|
||||
t.find(TerminalKey.arrowUp, newLineMode: true, appKeyPad: true, appScreen: true, macos: true)?.action.value,
|
||||
'match',
|
||||
);
|
||||
});
|
||||
|
||||
test('-Ansi records are skipped (VT52 not supported yet)', () {
|
||||
final t = build('key Up -Ansi : "vt52"');
|
||||
expect(t.find(TerminalKey.arrowUp), isNull);
|
||||
});
|
||||
|
||||
test('returns null when no record key matches', () {
|
||||
final t = build('key Up : "\\EA"');
|
||||
expect(t.find(TerminalKey.arrowDown), isNull);
|
||||
});
|
||||
|
||||
test('appCursorKeys + keyPad gates also filter', () {
|
||||
final t = build('key Up +AppCuKeys+KeyPad : "\\EOA"\nkey Up : "fallback"');
|
||||
expect(t.find(TerminalKey.arrowUp)?.action.value, 'fallback');
|
||||
expect(
|
||||
t.find(TerminalKey.arrowUp, appCursorKeys: true, keyPad: true)?.action.value,
|
||||
r'\EOA',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Default keytab is parsable + nontrivial', () {
|
||||
test('Keytab.defaultKeytab name + at least one record', () {
|
||||
expect(Keytab.defaultKeytab.name, isNotEmpty);
|
||||
expect(Keytab.defaultKeytab.records, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('TerminalKeyboardEvent', () {
|
||||
test('copyWith overrides only specified fields', () {
|
||||
final base = _evt(TerminalKey.arrowUp);
|
||||
final shifted = base.copyWith(shift: true);
|
||||
expect(shifted.shift, isTrue);
|
||||
expect(shifted.alt, base.alt);
|
||||
expect(shifted.ctrl, base.ctrl);
|
||||
expect(shifted.key, base.key);
|
||||
expect(shifted.platform, base.platform);
|
||||
});
|
||||
|
||||
test('copyWith() with no args is equivalent to the original', () {
|
||||
final base = _evt(TerminalKey.arrowDown, alt: true);
|
||||
final clone = base.copyWith();
|
||||
expect(clone.key, base.key);
|
||||
expect(clone.alt, base.alt);
|
||||
});
|
||||
});
|
||||
|
||||
group('CascadeInputHandler', () {
|
||||
test('returns the first non-null result; null otherwise', () {
|
||||
const cascade = CascadeInputHandler([
|
||||
_NullHandler(),
|
||||
_ConstHandler('first'),
|
||||
_ConstHandler('second'), // should never be reached
|
||||
]);
|
||||
expect(cascade(_evt(TerminalKey.arrowUp)), 'first');
|
||||
|
||||
const allNull = CascadeInputHandler([_NullHandler(), _NullHandler()]);
|
||||
expect(allNull(_evt(TerminalKey.arrowUp)), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('KeytabInputHandler', () {
|
||||
test('falls back to Keytab.defaultKeytab when none is supplied', () {
|
||||
// Up arrow with no modifiers → default keytab match (\E[A or similar
|
||||
// depending on mode flags). Just assert non-null.
|
||||
const h = KeytabInputHandler();
|
||||
expect(h(_evt(TerminalKey.arrowUp)), isNotNull);
|
||||
});
|
||||
|
||||
test('returns null when the keytab has no matching record', () {
|
||||
final empty = Keytab(name: 'empty', records: const []);
|
||||
expect(KeytabInputHandler(empty)(_evt(TerminalKey.arrowUp)), isNull);
|
||||
});
|
||||
|
||||
test('inserts a modifier code into actions containing *', () {
|
||||
// Build a keytab whose Up record uses the * placeholder; the handler
|
||||
// replaces * with a code based on the active modifiers.
|
||||
final t = Keytab.parse('keyboard "X"\nkey Up +AnyMod : "\\E[1;*A"\n');
|
||||
final h = KeytabInputHandler(t);
|
||||
|
||||
final cases = <(TerminalKeyboardEvent, String)>[
|
||||
// Single-modifier codes.
|
||||
(_evt(TerminalKey.arrowUp, shift: true), '\x1b[1;2A'), // shift → 2
|
||||
(_evt(TerminalKey.arrowUp, alt: true), '\x1b[1;3A'), // alt → 3
|
||||
(_evt(TerminalKey.arrowUp, ctrl: true), '\x1b[1;5A'), // ctrl → 5
|
||||
// Pair codes.
|
||||
(
|
||||
_evt(TerminalKey.arrowUp, shift: true, alt: true),
|
||||
'\x1b[1;4A',
|
||||
),
|
||||
(
|
||||
_evt(TerminalKey.arrowUp, shift: true, ctrl: true),
|
||||
'\x1b[1;6A',
|
||||
),
|
||||
(
|
||||
_evt(TerminalKey.arrowUp, ctrl: true, alt: true),
|
||||
'\x1b[1;7A',
|
||||
),
|
||||
// Triple.
|
||||
(
|
||||
_evt(TerminalKey.arrowUp, shift: true, alt: true, ctrl: true),
|
||||
'\x1b[1;8A',
|
||||
),
|
||||
];
|
||||
for (final c in cases) {
|
||||
expect(h(c.$1), c.$2, reason: '$c');
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves the action alone when no * placeholder is present', () {
|
||||
final t = Keytab.parse('keyboard "X"\nkey Up : "\\E[A"\n');
|
||||
final h = KeytabInputHandler(t);
|
||||
expect(h(_evt(TerminalKey.arrowUp)), '\x1b[A');
|
||||
});
|
||||
});
|
||||
|
||||
group('CtrlInputHandler', () {
|
||||
const h = CtrlInputHandler();
|
||||
|
||||
test('Ctrl+A through Ctrl+Z map to control bytes 0x01..0x1A', () {
|
||||
for (var i = 0; i < 26; i++) {
|
||||
final key = TerminalKey.values[TerminalKey.keyA.index + i];
|
||||
final result = h(_evt(key, ctrl: true));
|
||||
expect(result, String.fromCharCode(i + 1), reason: 'TerminalKey.${key.name} → ${i + 1}');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null without ctrl, or when shift / alt are also pressed', () {
|
||||
expect(h(_evt(TerminalKey.keyA)), isNull);
|
||||
expect(h(_evt(TerminalKey.keyA, ctrl: true, shift: true)), isNull);
|
||||
expect(h(_evt(TerminalKey.keyA, ctrl: true, alt: true)), isNull);
|
||||
});
|
||||
|
||||
test('returns null for non-letter keys', () {
|
||||
expect(h(_evt(TerminalKey.arrowUp, ctrl: true)), isNull);
|
||||
expect(h(_evt(TerminalKey.f1, ctrl: true)), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('AltInputHandler', () {
|
||||
const h = AltInputHandler();
|
||||
|
||||
test('Alt+A through Alt+Z emit ESC + uppercase ASCII byte', () {
|
||||
for (var i = 0; i < 26; i++) {
|
||||
final key = TerminalKey.values[TerminalKey.keyA.index + i];
|
||||
final result = h(_evt(key, alt: true));
|
||||
expect(result, '\x1b${String.fromCharCode(0x41 + i)}');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null without alt, or when shift / ctrl are also pressed', () {
|
||||
expect(h(_evt(TerminalKey.keyA)), isNull);
|
||||
expect(h(_evt(TerminalKey.keyA, alt: true, ctrl: true)), isNull);
|
||||
expect(h(_evt(TerminalKey.keyA, alt: true, shift: true)), isNull);
|
||||
});
|
||||
|
||||
test('returns null on macOS (Alt is reserved for char composition)', () {
|
||||
expect(
|
||||
h(_evt(TerminalKey.keyA, alt: true, platform: TerminalTargetPlatform.macos)),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for non-letter keys', () {
|
||||
expect(h(_evt(TerminalKey.arrowUp, alt: true)), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('defaultInputHandler', () {
|
||||
test('routes a plain Up arrow through the keytab', () {
|
||||
expect(defaultInputHandler(_evt(TerminalKey.arrowUp)), isNotNull);
|
||||
});
|
||||
|
||||
test('routes Ctrl+C to 0x03 via CtrlInputHandler when keytab misses', () {
|
||||
// Default keytab has no entry for Ctrl+keyC, so CtrlInputHandler runs.
|
||||
expect(
|
||||
defaultInputHandler(_evt(TerminalKey.keyC, ctrl: true)),
|
||||
'\x03',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _NullHandler implements TerminalInputHandler {
|
||||
const _NullHandler();
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) => null;
|
||||
}
|
||||
|
||||
class _ConstHandler implements TerminalInputHandler {
|
||||
const _ConstHandler(this._value);
|
||||
final String _value;
|
||||
@override
|
||||
String? call(TerminalKeyboardEvent event) => _value;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/// Verifies that the bundled JetBrainsMono Bold face has identical
|
||||
/// advance widths to Regular at our render size — required for the
|
||||
/// terminal cell grid to stay stable when bold attributes flip on.
|
||||
///
|
||||
/// If this fails, the workaround in painter.dart was deactivated
|
||||
/// against a font that drifts; pick one of the alternatives in T-73
|
||||
/// (variable JetBrainsMono / Berkeley Mono / IBM Plex Mono).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _family = 'JetBrainsMonoTest';
|
||||
const _fontSize = 14.0;
|
||||
const _sample = 'mmmmmmmmmm';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
final regular = await File('assets/fonts/jetbrains_mono/JetBrainsMono-Regular.ttf').readAsBytes();
|
||||
final bold = await File('assets/fonts/jetbrains_mono/JetBrainsMono-Bold.ttf').readAsBytes();
|
||||
|
||||
final loader = FontLoader(_family)
|
||||
..addFont(Future.value(ByteData.sublistView(regular)))
|
||||
..addFont(Future.value(ByteData.sublistView(bold)));
|
||||
await loader.load();
|
||||
});
|
||||
|
||||
test('JetBrainsMono Bold advance width matches Regular (cell drift = 0)', () {
|
||||
final regularWidth = _measure(FontWeight.normal);
|
||||
final boldWidth = _measure(FontWeight.bold);
|
||||
expect(boldWidth, regularWidth,
|
||||
reason: 'Bold advance width must equal Regular at $_fontSize px '
|
||||
'or the terminal cell grid drifts when bold flips on.');
|
||||
});
|
||||
}
|
||||
|
||||
double _measure(FontWeight weight) {
|
||||
final builder = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontFamily: _family,
|
||||
fontSize: _fontSize,
|
||||
))
|
||||
..pushStyle(ui.TextStyle(
|
||||
fontFamily: _family,
|
||||
fontWeight: weight,
|
||||
fontSize: _fontSize,
|
||||
))
|
||||
..addText(_sample);
|
||||
final paragraph = builder.build()..layout(const ui.ParagraphConstraints(width: double.infinity));
|
||||
final width = paragraph.maxIntrinsicWidth;
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:clide/widgets/src/icons/x.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -20,8 +19,7 @@ MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable
|
||||
);
|
||||
}
|
||||
|
||||
Widget body(BuildContext _, MultitabEntry<String> e) =>
|
||||
SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
|
||||
Widget body(BuildContext _, MultitabEntry<String> e) => SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
|
||||
|
||||
/// Stateful tap-counter body. Preserves a per-id count across rebuilds
|
||||
/// in a static map so the test can assert state survival across tab
|
||||
|
||||
Reference in New Issue
Block a user