22 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 1fb4786f18 sweep remaining analyze infos to zero
test / unit + widget + golden + a11y (push) Failing after 35s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
Clears the 19 misc lint hits left after the test_app print sweep
+ libc.dart suppression. By rule:

- `withOpacity(α)` → `withValues(alpha: α)` (deprecated_member_use)
  in `painter.dart:187` and `terminal_view.dart:318`.
- `Pointer.elementAt(n)` → `Pointer + n` (deprecated_member_use)
  in `native_pty.dart:306` and `session.dart:187`.
- Brace single-statement for/if bodies in `native_pty.dart`
  (×3) and `decisions_view.dart` (curly_braces_in_flow_control_
  structures).
- `IsolateClient` and `InProcessClient` constructors switched to
  `super.log` / `super.events` parameters (use_super_parameters);
  associated unused imports of `kernel/src/log.dart` and
  `kernel/src/events/bus.dart` removed in the same files.
- `InProcessClient._dispatcher` field + getter/setter pair folded
  into a single mutable public `dispatcher` field
  (unnecessary_getters_setters).
- `_buildDispatcher` local in `lib/main.dart` renamed to
  `buildDispatcher` (no_leading_underscores_for_local_identifiers).
- `_onTapDown(_)` in `terminal_view.dart` typed as
  `TapDownDetails _` (strict_top_level_inference).
- `operator []=(...)` in `circular_buffer.dart` given an explicit
  `void` return type (strict_top_level_inference).
- `CustomKeyboardListener` and `TerminalGestureDetector` callsites
  reordered so `child:` lands last (sort_child_properties_last).
- `CustomTextEdit` constructor declared `const`
  (prefer_const_constructors_in_immutables).
- `LinkedHashMap<K, V>()` in `paragraph_cache.dart` collapsed to a
  `<K, V>{}` literal (prefer_collection_literals); the now-unused
  `dart:collection` import dropped.

Project analyze: 19 → 0 issues. `make test` stays green; coverage
unchanged at 52.72%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:51:46 +02:00
jpmschweitzerandClaude 2d26af5934 suppress POSIX-shaped lint hits in libc FFI bindings
`lib/src/pty/ffi/libc.dart` carried 34 analyze infos:

- 26 × `non_constant_identifier_names` on struct field names
  (`msg_name`, `iov_base`, `msg_controllen`, etc.) that map 1:1
  to POSIX (`man 2 socketpair`, `recvmsg`, `iovec`, `msghdr`).
- 8 × `library_private_types_in_public_api` on the C / Dart
  function-signature typedefs (`_SocketpairC`, `_SocketpairDart`,
  etc.) consumed only by the `lookupFunction<...>()` calls in
  this same file.

Renaming the field names to lowerCamelCase would diverge from the
spec the file documents itself against; promoting the typedefs to
public would just add noise to the import surface. This is the
textbook FFI-binding case where the lints work against the file's
purpose.

Adds a file-wide `// ignore_for_file:` directive — explicitly
approved per the no-lint-suppression rule, with the reason
written inline above the directive so a future reader can
re-evaluate it.

Project analyze drops 65 → 31 issues.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:46:36 +02:00
jpmschweitzerandClaude 26cc1b3154 drop redundant single-symbol imports across the tree
12 imports flagged by `unnecessary_import` because the symbols
they bring in are also re-exported by the umbrella import already
present in the same file:

- bin/clide.dart: src/git/client.dart, src/pql/client.dart
  (covered by package:clide/clide.dart).
- lib/builtin/decisions/, lib/builtin/tickets/ (4 files):
  kernel/src/events/message_bus.dart (covered by kernel.dart).
- lib/kernel/src/ipc/in_process.dart: src/daemon/dispatcher.dart
  (covered by clide.dart).
- lib/main.dart: kernel/src/toolchain.dart (covered by kernel.dart).
- test/builtin/ipc_status/widget_test.dart:
  builtin/ipc_status/src/status_item.dart (covered by
  ipc_status.dart).
- test/daemon/{git,pql}_commands_test.dart: src/git/client.dart and
  src/pql/client.dart (covered by clide.dart).
- test/widgets/multitab_pane_test.dart: widgets/src/icons/x.dart
  (covered by widgets.dart).

Mechanical change — every removed line was already a no-op for
symbol resolution; the umbrella imports define the public surface
each file is actually using.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:46:24 +02:00
jpmschweitzerandClaude 0f2f5180ee route testmode output through the kernel logger
`lib/test_app.dart` printed [testmode] / [testmode:json] lines via
the bare `print` builtin, which tripped the `avoid_print` analyze
rule 38 times — by far the loudest source of analyze noise in the
tree.

Routes everything through a `Logger()` instance held on
`_ClideTestAppState`, with a small `_say(msg)` helper for
human-readable lines and a separate `'testmode:json'` source for
the structured summary the harness greps. The default Logger sink
is stderr; `make run-testmode` already pipes `2>&1`, so the
existing `grep -q '"failed":0'` check is unaffected.

Also drops the now-redundant kernel sub-imports (events/bus,
events/types, log, toolchain) — `kernel/kernel.dart` re-exports
them, and the analyzer flagged the doubles as unnecessary.

Project analyze: 107 → 65 issues. test_app.dart is now clean
(0 issues, was 42).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:42:34 +02:00
jpmschweitzerandClaude 257a333904 drop debug main() from keytab_default.dart
The file's only purpose is to expose the default-keytab string
constant, but it carried a `void main()` at the end that parsed
that constant and printed the result. That entry point:

- doesn't belong in `lib/` (Dart entry points live in `bin/` or
  `tool/`),
- pulls in `keytab_parse` and `keytab_token` imports that are
  unused everywhere else in the file,
- emits one of the pre-existing `avoid_print` analyze infos,
- only ever ran when a contributor manually invoked
  `dart lib/src/terminal/src/core/input/keytab/keytab_default.dart`,
  which the build never does.

Removing it unblocks the file from the coverage report (no
executable lines remain, just the string constant), drops the
unused imports, and shaves an analyze info off the pre-existing
total. If the dump-to-stdout helper turns out to be useful again,
the right home is a `tool/dump_keytab.dart` outside the package's
runtime surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:37:13 +02:00
jpmschweitzerandClaude 94abebf904 remove dead defensive throws in keytab tokenizer + parser
Four `throw` sites in `core/input/keytab/` were unreachable through
the public API:

- `keytab_token.dart` `_parseKeyboardNameDefine` and `_parseKeyDefine`
  each tested `reader.readString() == 'keyboard'` / `'key'` after
  the caller in the same file (`tokenize`) had already gated entry
  on `_isKeyboardNameDefine` / `_isKeyDefine`. Both checks
  redundantly re-derived a fact already established a function
  call earlier; the `else { throw }` was dead code.
- `keytab_parse.dart` `_parseName` and `_parseKeyDefine` checked
  the first token's type, but `addTokens` only delegates to those
  functions after `peek().type` matches the expected kind. Same
  pattern: the throw protects an invariant the caller already
  enforces.

Surfaced while bringing `core/input/` to ~100% coverage. Per the
"near-perfect discipline" / "no carve-outs" rules, dead defensive
code is cleaned, not skipped — the surrounding callers in the same
file are tight enough that introducing a real callsite gap would
be a localised and obvious bug, not a silent failure rescued by
these guards.

The two `else`-throw sites in keytab_token.dart fold into a single
unconditional `reader.readString()` (consume the leading word) +
`yield` of the matching token type. The two type-check throws in
keytab_parse.dart fold into an unconditional `reader.take()` to
skip the already-validated token.

All public-API ParseError paths exercised by `core/input/`'s
unit tests still throw correctly — they're guarded by the second
check in each function (the action-token type check after
modeStatus loops, and the input-token check in _parseName).

After cleanup:
- keytab_token.dart: 80 / 80
- keytab_parse.dart: 63 / 63

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:37:05 +02:00
jpmschweitzerandClaude f3e164b834 test sweep: cover core/input/ keytab + handlers (T-91)
Adds test/terminal/input/input_test.dart — 58 unit tests covering
the keytab tokenizer, parser, unescape helper, KeytabRecord
toString shapes, Keytab.find modifier-matching rules, and the four
TerminalInputHandler implementations (CascadeInputHandler,
KeytabInputHandler, CtrlInputHandler, AltInputHandler).

Highlights:

- keytabUnescape: every documented backslash escape + \xHH hex.
- LineReader: peek/take/done, whitespace skip, readString
  (alphanumeric/underscore), readUntil (both exclusive and
  inclusive variants).
- tokenize: keyboard-name and key-define lines, comment + blank
  stripping, shortcut vs string actions, error paths on malformed
  input.
- KeytabParser: full mode-flag matrix, error paths on every
  defensive throw reachable through the public addTokens API
  (stray non-keyboard token, missing colon, modeStatus value other
  than '+'/'-', non-mode token after modeStatus, action token of
  wrong type, second token of wrong kind for both _parseName and
  _parseKeyDefine).
- KeytabRecord.toString covers every supported flag (Alt, Control,
  Shift, AnyMod, Ansi, AppScreen, KeyPad, AppCuKeys, AppKeyPad,
  NewLine, Mac).
- Keytab.find: -Shift / +AnyMod / -AnyMod gating, mode-flag
  filters (newLine, appKeyPad, appScreen, macos, appCursorKeys,
  keyPad), -Ansi (VT52) skip, fallthrough to fallback record,
  null when no key matches.
- KeytabInputHandler: every modifier combination's `*` placeholder
  expansion (1..8 inclusive), default-keytab fallback, no-match
  null, no-* passthrough.
- CtrlInputHandler: A..Z → 0x01..0x1A; null without ctrl, with
  shift / alt, or on non-letter keys.
- AltInputHandler: A..Z → ESC + uppercase; null without alt, with
  shift / ctrl, on macOS, or on non-letter keys.
- defaultInputHandler integration: keytab routing, fallthrough to
  CtrlInputHandler.

Coverage delta:
- core/input/handler.dart: 4/54 → 54/54.
- keytab.dart: 0/29 → 29/29.
- keytab_record.dart: 0/44 → 44/44.
- keytab_token.dart: 0/82 → 80/82 (the two remaining lines are
  defensive throws inside `_parseKeyboardNameDefine` /
  `_parseKeyDefine` that are unreachable from tokenize() — the
  callers only enter those functions after the `_isKeyboardNameDefine`
  / `_isKeyDefine` guards in the same file, so the inner readString
  always matches).
- keytab_parse.dart: 0/65 → 63/65 (the two remaining lines mirror
  the same shape — _parseName and _parseKeyDefine both check the
  first token's type, but addTokens only delegates to them after
  matching that type, so the throws are dead defensive code).
- keytab_default.dart: 0/4 unchanged — that's the file's own
  `void main()` debug entrypoint that prints the parsed default
  keytab; not part of the runtime contract.
- keytab_escape.dart: 0/14 → 14/14.
- Total project: 49.31% → 52.53%; coverage_floor bumped 49 → 52.

The 4 dead defensive throws are flagged but not removed in this
commit — they're a code-style call (defensive paranoia vs. dead-
code cleanup) that belongs in a separate review, not folded into a
test sweep.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:33:57 +02:00
jpmschweitzerandClaude 3b352fe654 remove obsolete dead-code suppression from SGR loop
`_csiHandleSgr` carried a `// ignore: dead_code` directive with the
note "workaround for a bug in the analyzer". Re-running the
analyzer with the suppression removed produces no warning — Dart's
flow analysis has caught up since the comment was added.

Per the no-lint-suppression rule the suppression needed to be
either removed or given a more substantive justification; the
analyzer's silence makes the call easy.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:25:59 +02:00
jpmschweitzerandClaude 9331055b44 test sweep: cover EscapeParser + EscapeEmitter (T-91)
Adds test/terminal/escape/parser_test.dart — 70 unit tests covering
the parser's dispatch surface end-to-end:

- single-byte controls (BEL, BS, HT, LF/VT/FF, CR, SO, SI),
- ESC sequences (D, E, H, M, =, >, 7, 8, ( name, ) name, unknown),
- CSI cursor moves (A/B/C/D/E/F/G/H/d/f) with default + 0-as-1
  fallback semantics,
- erase / scroll / line-insert/delete / chars (J, K, L, M, P, S, T,
  X, @, b, g, r),
- device attributes (c / >c / =c) and DSR (5, 6),
- window manipulation (CSI 8 t resize, CSI 18 t sendSize, ignored
  no-op codes, malformed CSI 8 t),
- mode set/reset (h/l, ? prefix for DEC modes — covering ?1, ?3,
  ?5, ?6, ?7, ?9, ?12, ?25, ?47, ?66, ?1000, ?1002, ?1003, ?1004,
  ?1005, ?1006, ?1007, ?1015, ?1047, ?1048, ?1049, ?2004, +
  unknown fallback),
- SGR styling (resets, set/unset for every attr, 16-colour
  foreground + background, 256-colour, 24-bit RGB, 39 / 49
  resets, unknown → unsupportedStyle),
- OSC 0/1/2 (BEL- and ST-terminated), unknown OSC, incomplete
  sequence held back across writes,
- unknown CSI final byte → unknownCSI,
- token bookkeeping (tokenBegin / tokenEnd advance with consumed
  bytes).

Plus EscapeEmitter — every reply string format (primary /
secondary / tertiary device attributes, operating status, cursor
position, bracketed paste, size).

Coverage delta:
- parser.dart: 0 / 462 → 504 / 514 (98.1%; the remaining 10 lines
  sit inside the `// ignore: dead_code` SGR loop, which I'm
  surfacing for separate review rather than extending tests
  around).
- emitter.dart: 1 / 11 → 11 / 11.
- Total project: 43.20% → 49.38%; coverage_floor bumped 43 → 49.

Two real source-code issues found while writing tests, fixed in
the same commit:

1. Swapped docstrings on `_escHandleSetAppKeypadMode` /
   `_escHandleResetAppKeypadMode`. The function names + dispatch
   table + bodies all match the VT spec correctly (ESC = enables,
   ESC > disables); only the doc-comments were swapped. Now read
   the right way around.
2. `case 10061000:` in `_setDecMode` was unreachable (no DEC mode
   has that value). Almost certainly a typo where `case 1006:`
   meant to glue onto `case 1000:` but a newline went missing.
   Mode 1006 is already handled separately at its own clause as
   `MouseReportMode.sgr`. Removed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:24:12 +02:00
jpmschweitzerandClaude d47d01495c allow awk in project bash permissions
Coverage parsing, lcov triage, and quick log scans use awk one-liners
constantly. Adding `Bash(awk *)` to the project allowlist removes
the permission prompt without weakening the deny rules.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:23:34 +02:00
jpmschweitzerandClaude c233b7607d test sweep: cover Buffer (T-91)
Adds test/terminal/buffer/buffer_test.dart — 67 unit tests against
the Buffer orchestrator on top of BufferLine. Drives a fake
TerminalState through writes, cursor moves, scroll regions,
erase commands, line insert/delete, resize (with and without
reflow), word-boundary lookup, getText, and the toString debug
dump.

Coverage delta:
- buffer.dart: 0 / 260 → 260 / 261 (one while-loop-body line
  Dart coverage doesn't instrument distinctly; the loop's effect
  is exercised end-to-end by the reflow-pad test).
- Total project: 39.12% → 43.20%.
- pubspec.yaml `coverage_floor:` bumped 39 → 43.

Notes:
- The fake TerminalState (`_State`) is a per-file impl rather than
  a shared fixture; it stays close to the test that exercises it
  and avoids forcing other terminal tests to depend on a one-shape-
  fits-all stub.
- Tests that walk through `lineFeed` use `lineFeedMode: true` so
  the column resets between newlines — otherwise the saturated
  cursor X from a previous full-width write spills the next write
  onto an extra line via `writeChar`'s autoWrap branch.

This closes the `core/buffer/` sub-area for T-91 — every leaf file
in `lib/src/terminal/src/core/buffer/` is now at >= 96% line
coverage; the only outliers are Dart-coverage-instrumentation
quirks, not real gaps.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:13:42 +02:00
jpmschweitzerandClaude c744c8ce4b test sweep: cover BufferLine + CellAnchor (T-91)
Adds test/terminal/buffer/line_test.dart — 51 unit tests covering
BufferLine and CellAnchor, hitting every reachable line in
lib/src/terminal/src/core/buffer/line.dart (192 / 192).

Coverage delta:
- line.dart: 0 / 194 → 192 / 192 (file shrank by two lines after
  the prior commit's iteration fix folded two for-loop heads into
  for-each-toList).
- Total project: 36.39% → 39.12%.
- pubspec.yaml `coverage_floor:` bumped 36 → 39 in lockstep.

Highlights:
- All packed-cell encodings (foreground/background/attrs/content
  channels, codepoint+width packing, CellData round-trips).
- `eraseRange` wide-char neighbor extension on both ends.
- `removeCells` / `insertCells` shift logic, anchor reposition, and
  the wide-tail-erase branch (insertCells case where the post-shift
  last cell carries a wide marker).
- `resize` exercising the [64, 256) capacity-doubling branch and
  the >=256 +32 branch separately.
- `getTrimmedLength` cols-clamp behaviour for null/over-capacity.
- `getText` skip-trailing-wide-char branch.
- `CellAnchor` lifecycle: detached construction, `reposition`,
  `reparent` (both detached→attached and between owners), `dispose`,
  attached y/offset via a real IndexAwareCircularBuffer.

Also cleans up five `unrelated_type_equality_checks` analyze infos
in test/terminal/buffer/range_test.dart by typing the RHS as Object
when intentionally probing the type-mismatch branch of operator==.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:03:21 +02:00
jpmschweitzerandClaude 1d75e8e1cd fix concurrent-modification in BufferLine anchor iteration
`removeCells`, `insertCells`, and `dispose` each iterate over
`_anchors` while invoking `anchor.dispose()` on entries inside the
loop — but `dispose()` removes the anchor from the same list, which
shifts later indexes left and causes the for-loop to skip them.

Symptoms (no user-facing report yet, but real correctness bug):
- After `removeCells` with multiple anchors past the start, anchors
  that should be repositioned were silently left at their old `x`.
- After `insertCells` with anchors getting pushed past `_length`,
  ones meant to be disposed could survive.
- `BufferLine.dispose` would throw `ConcurrentModificationError` as
  soon as more than one anchor was attached.

Fix: iterate `_anchors.toList()` (a snapshot) in all three sites.
Cheap, safe, and matches the expected anchor-management semantics.

Surfaced by the unit tests added under T-91; that commit covers the
fix with regression tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 23:03:03 +02:00
jpmschweitzerandClaude eb32396bd4 test sweep: cover BufferRange family (T-91)
Adds test/terminal/buffer/range_test.dart — 38 unit tests covering
the small pure-Dart files in lib/src/terminal/src/core/buffer/:

- cell_offset.dart       23 / 23  (was 0 / 23)
- range.dart             13 / 13  (was 0 / 13)
- segment.dart           13 / 13  (was 0 / 13)
- range_line.dart        30 / 30  (was 0 / 30)
- range_block.dart       48 / 48  (was 0 / 48)

Total project line coverage 34.90% → 36.39%; coverage_floor in
pubspec.yaml bumped 34 → 36 in lockstep.

Tests exercise the abstract BufferRange operator==/hashCode/toString
via a local _StubRange (BufferRangeLine and BufferRangeBlock both
override those, so the base versions are otherwise unreachable —
worth a stub rather than carving the lines out of coverage). The
denormalized-input branches in Block contain/toSegments/extend get
explicit cases too.

Pure Dart, no Flutter dependency — uses package:test/test.dart and
runs in <100ms.

First batch under T-91; line.dart and buffer.dart land in subsequent
commits with their own floor bumps.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:55:15 +02:00
jpmschweitzerandClaude f204e91f29 fold redundant test_coverage.sh into the gate
`ci/test.sh` now runs `flutter test --coverage`, so
`ci/test_coverage.sh` was just re-running the same tests plus an
optional `lcov --summary` that needs `lcov` installed (it wasn't,
on at least this machine). Removing it.

- ci/test_coverage.sh: deleted.
- Makefile: drop the `coverage` target (it only wrapped the dead
  script). Fix a stale `coverage/floor.txt` reference in the
  `coverage-gate` help text — the floor lives in pubspec.yaml now.
- .gitea/workflows/test.yml: replace the test_coverage.sh invocation
  with ci/coverage_gate.sh, so CI enforces the same floor as the
  pre-push hook (defense in depth).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:47:49 +02:00
jpmschweitzerandClaude 1151a0ce80 install ratcheting line-coverage gate at 34% floor (T-90)
First child of T-89. Codifies "don't make coverage worse" as a
durable pre-push contract before any test-writing children land.

- pubspec.yaml: new `coverage_floor: 34` key. Single source of
  truth for the floor; ratchets up only.
- ci/coverage_gate.sh: parses coverage/lcov.info (LH/LF), reads
  the floor from pubspec.yaml, exits non-zero if integer-truncated
  measured % drops below it. Self-contained awk parser — no `lcov`
  CLI dependency.
- ci/test.sh: flutter test now runs with --coverage, so the gate
  reads fresh data without an extra test invocation. Wall time
  delta is small and stays inside the < 90 s pre-push budget
  (D-29).
- Makefile: new `coverage-gate` target wires the script in;
  `push-check` adds it as a dependency. The .githooks/pre-push
  hook (already wired) picks this up automatically.
- .gitignore: ignore /coverage/ wholesale; the floor lives in
  pubspec.yaml, nothing under coverage/ is committed.

Decision recorded as D-66 (decisions/testing.md). End target is
95%; reaching it is tracked as the rest of T-89's children.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:44:43 +02:00
jpmschweitzerandClaude a67a768592 restore semantic bold rendering in terminal panes (T-73)
Bold attributes from terminal escapes now render in a real bold
weight instead of being silently flattened.

- pubspec.yaml: register JetBrainsMono Bold + BoldItalic at
  weight 700 under family JetBrainsMono. Files already shipped on
  disk; only the registration was missing.
- assets/licenses.yaml: bump JetBrainsMono weights_bundled to
  [Regular, Italic, Bold, BoldItalic] per D-42 (the entry must
  match what is actually wired into the family).
- lib/src/terminal/src/ui/painter.dart: revert the `bold: false`
  override and drop the workaround comment. Bold now flows from
  CellFlags.bold to TextStyle.fontWeight.
- test/terminal/painter_bold_metrics_test.dart: load Regular and
  Bold via FontLoader and assert paragraph maxIntrinsicWidth is
  identical (cell-grid drift = 0). JetBrainsMono Bold's monospace
  by spec; this test is the canary for the day someone swaps the
  font.
- test/goldens/goldens/{ci,linux}/clide_button.png: regenerate.
  ClideButton's label renders slightly heavier on the bold variant
  (expected — 0.28% pixel diff before regen).

Earlier perception of over-bolding in the Claude pane was
synthetic-bold smearing (Flutter overpaints when no Bold.ttf is
registered for the family), not legitimate bold rendering. Visual
A/B confirms a real Bold face renders crisp emphasis without the
smear, so no per-pane renderer config is needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:29:23 +02:00
jpmschweitzerandClaude 699bd40423 fix Overlay sizing in widget_harness for Flutter 3.27+
Flutter 3.27 changed Overlay layout: an Overlay given infinite
height constraints now requires at least one OverlayEntry with
`canSizeOverlay: true` to delegate sizing, otherwise the entire
golden suite throws "Overlay was given infinite constraints" before
any test can render.

Marking the harness's only entry as size-determining is the minimal
fix — keeps the existing MediaQuery-driven layout shape intact and
unblocks every widget/golden test that uses `harness()`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:29:05 +02:00
jpmschweitzerandClaude bfa128f131 drop dead mouse imports from terminal_view
`mouse/button.dart` and `mouse/button_state.dart` were imported but
nothing in terminal_view referenced their symbols — analyzer
warnings, not infos. Removed.

Probable origin: a half-landed mouse-forwarding refactor (the actual
work is now scoped under T-74); the imports can come back when the
real wiring lands. Removing them in the meantime keeps the analyze
gate clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:28:58 +02:00
jpmschweitzerandClaude f0bb2ffcce format the tree to current dart format spec
Mechanical `dart format` sweep across files that drifted from the
formatter's output (mostly trailing-comma and line-wrap differences
from a Dart SDK / formatter version bump). No semantic changes.

Caught because the pre-push gate now actually fires.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:28:51 +02:00
jpmschweitzerandClaude 301322a6b0 promote no-pre-existing-excuse to a CLAUDE.md guardrail
Memory-only "if you encounter a failure, fix it first" advice keeps
losing to the model's default scope-protection behaviour: when a
test is red or analyze warns on entry, the safer-feeling option is
to flag and continue rather than fix and continue. Promoting the
rule into the load-bearing guardrails list makes it sit in the same
register as "Flutter desktop is the host" — non-negotiable, not
advisory.

Pairs with the .githooks/pre-push gate landed alongside: that
prevents broken state from being pushed in the first place; this
prevents the next session from building on broken state if it slips
through.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:28:25 +02:00
jpmschweitzerandClaude 53a61d9269 add pre-push hook plumbing under .githooks/
`make hooks` already sets `core.hooksPath=.githooks/`, and the
pre-push gate at `.githooks/pre-push` already runs `make push-check`
— but the pql-installed pre-commit and post-merge shims live at
`.git/hooks/`, which take precedence and silently disable .githooks/.

Add the missing pre-commit / post-merge shims under .githooks/ so
`make hooks` becomes a single-step install: pre-push enforcement,
pql planning-state auto-export on commit, and auto-import on pull
all fire from the canonical .githooks/ location.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 22:28:15 +02:00
73 changed files with 3894 additions and 461 deletions
+2 -1
View File
@@ -29,7 +29,8 @@
"Bash(flutter *)",
"Bash(make *)",
"Bash(pql)",
"Bash(pql *)"
"Bash(pql *)",
"Bash(awk *)"
],
"deny": [
"Bash(rm -rf /*)",
+1 -1
View File
@@ -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)
+3
View File
@@ -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"
+3
View File
@@ -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"
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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` —
+1
View File
@@ -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
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -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
-2
View File
@@ -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) {
+50
View File
@@ -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
View File
@@ -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
-14
View File
@@ -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
+8
View File
@@ -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.
---
+15 -17
View File
@@ -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(
+2 -7
View File
@@ -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;
-1
View File
@@ -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 {
-1
View File
@@ -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';
+6 -13
View File
@@ -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);
}
}
+3 -4
View File
@@ -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
View File
@@ -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));
},
);
+1 -2
View File
@@ -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 {
+18
View File
@@ -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;
+12 -8
View File
@@ -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) {
+2 -6
View File
@@ -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) {
+1 -3
View File
@@ -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;
}
+8 -5
View File
@@ -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
+3 -10
View File
@@ -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;
}
+12 -31
View File
@@ -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();
+2 -4
View File
@@ -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();
+2 -4
View File
@@ -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(
+4 -10
View File
@@ -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";
}
}
+1 -2
View File
@@ -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
+7 -13
View File
@@ -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 {
+1 -3
View File
@@ -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();
+4 -10
View File
@@ -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 -2
View File
@@ -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);
+3 -7
View File
@@ -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;
}
+2 -4
View File
@@ -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
View File
@@ -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));
}
}
+3 -8
View File
@@ -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),
),
),
);
}
}
+9
View File
@@ -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
View File
@@ -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';
-1
View File
@@ -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';
-1
View File
@@ -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() {
+4 -8
View File
@@ -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

+4 -1
View File
@@ -22,7 +22,10 @@ Widget harness(KernelFixture fixture, Widget child) {
data: const MediaQueryData(),
child: Overlay(
initialEntries: [
OverlayEntry(builder: (_) => child),
OverlayEntry(
canSizeOverlay: true,
builder: (_) => child,
),
],
),
),
+802
View File
@@ -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
});
});
}
+534
View File
@@ -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)));
});
});
}
+359
View File
@@ -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;
}
+827
View File
@@ -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');
});
});
}
+702
View File
@@ -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 -3
View File
@@ -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