Merge pull request #239 from levineuwirth/gui-stage1a-textinput
GUI Stage 1a — TextInput at protocol v24: multi-scalar input stops truncating
This commit is contained in:
commit
ca92796c71
|
|
@ -270,6 +270,92 @@ hazard in a shape that looks committed. **A documented error message
|
||||||
that never appears is worse than no documentation**, because the reader
|
that never appears is worse than no documentation**, because the reader
|
||||||
waits for a signal that is not coming.
|
waits for a signal that is not coming.
|
||||||
|
|
||||||
|
## GUI arc Stage 1a — `TextInput` at v24 — PR #239 OPEN
|
||||||
|
|
||||||
|
**Written with the branch's first commit**, per the standing correction
|
||||||
|
from #171 and #215.
|
||||||
|
|
||||||
|
- **PR #239** — https://github.com/levineuwirth/pmacs/pull/239.
|
||||||
|
- **Branch `gui-stage1a-textinput`**, base `githubsucks/main` @
|
||||||
|
`4f77491` exactly. **`githubsucks/gui-stage1a-textinput` is the
|
||||||
|
authoritative tip** — the ref, not a SHA. Recover with
|
||||||
|
`git fetch githubsucks && git checkout gui-stage1a-textinput`.
|
||||||
|
- **No new framing.** `docs/gui-stage1-input-framing.md` already governs
|
||||||
|
every Stage 1 slice; A1–A9, the eight Q#S1-9 precedence rules, §8's
|
||||||
|
wire contract and §11's gates are ruled there. Writing a 1a framing
|
||||||
|
would duplicate an approved document.
|
||||||
|
- **First commit is a GROUND-TRUTH RE-MEASUREMENT of §2 (revision 12),
|
||||||
|
not code.** §2 was taken at `a994f37`, before 1-pre moved almost every
|
||||||
|
GPU-side coordinate in it. It is refreshed here rather than in its own
|
||||||
|
PR so the contract and the code that depends on it are reviewed
|
||||||
|
together. **No ruling changes.**
|
||||||
|
- Moved: `window_event` `:2734`/655 lines → **`:4450`/four lines**;
|
||||||
|
`translate_key` `:10975` → **`:12053`**; the eight-arms description
|
||||||
|
→ three family decision functions over nine variants.
|
||||||
|
- **CORRECTION, wrong at BOTH anchors: "`KeyEvent.text` is never
|
||||||
|
read" is false** — the AltGr rule reads it (`a994f37:2800`, now
|
||||||
|
`main.rs:3251`). The true claim is narrower: `text` is never read as
|
||||||
|
the text a keypress **inserts**, only as a *discriminator*. This is
|
||||||
|
load-bearing for A5, because §5's rule 2 already exempts "printable
|
||||||
|
Ctrl+Alt recognized by the existing AltGr rule" — **1a widens `text`
|
||||||
|
from discriminator to payload, and that is the change of kind the
|
||||||
|
old wording hid.**
|
||||||
|
- **CORRECTION: A4's exit site.** 1-pre moved the mechanism without
|
||||||
|
changing behaviour. **A4 edits `apply_keyboard`'s branch and return
|
||||||
|
type, not `window_event`**, and **`EventOutcome` survives A4** — a
|
||||||
|
native close still returns `Exit`.
|
||||||
|
- **PROTOCOL-BEARING at v24 and therefore SERIALIZED.** `TextInput` is
|
||||||
|
an **appended** `FrontendEvent` variant; never widen a field in place,
|
||||||
|
because postcard is positional. `PROTOCOL_VERSION` is **23** at this
|
||||||
|
base and `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20** and must
|
||||||
|
not be edited to chase it. A frozen-byte pin goes on `FrontendEvent`'s
|
||||||
|
**previous final variant**, since an appended variant's own round-trip
|
||||||
|
cannot detect a discriminant shift.
|
||||||
|
- **A `PROTOCOL_VERSION` bump's blast radius is every version-sensitive
|
||||||
|
test and NONE of them appear in the diff** — handoff §1a records eight
|
||||||
|
such failures across six suites on the last bump, of which CI showed
|
||||||
|
one because cargo stops at the first failing target. **Sort them:** a
|
||||||
|
tripwire `assert_eq!(PROTOCOL_VERSION, N)` is meant to fire; an
|
||||||
|
absolute contract expressed as arithmetic on a moving constant is a
|
||||||
|
defect. **`ADVERTISED_PROTOCOL_VERSION == 20` must NOT fire.**
|
||||||
|
- **IMPLEMENTED. All sixteen gates green** under an isolated `TMPDIR`
|
||||||
|
(log `20260812T204615Z-215223`):
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/gate --protocol \
|
||||||
|
--acceptance gui_stage1a_acceptance \
|
||||||
|
--acceptance gui_stage1a_wire_acceptance \
|
||||||
|
--acceptance auto_pair_acceptance \
|
||||||
|
--acceptance discovery_stage2_acceptance \
|
||||||
|
--acceptance statusline_segments_acceptance \
|
||||||
|
--acceptance vterm_stage3_acceptance
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--protocol` is required** — 1a changes the wire — and it is what
|
||||||
|
adds the crdt build and the second sweep. `gui_stage1a_wire_acceptance`
|
||||||
|
is `#![cfg(feature = "crdt")]`, so it runs **2 tests in the crdt sweep
|
||||||
|
and 0 in the default one**; checked in the logs rather than assumed,
|
||||||
|
because a suite that compiles to nothing reports `ok`.
|
||||||
|
- **Evidence: 8 + 2 acceptance rows, 6 producer/router rows, and the
|
||||||
|
A1–A4 unit witnesses; mutations M-1a-1 … M-1a-6b, each failing the row
|
||||||
|
it targets.**
|
||||||
|
- `M-1a-1` single-scalar back through the generic insert → the record
|
||||||
|
row **and** the auto-pair row.
|
||||||
|
- `M-1a-2` `break_command_chain` deleted → the primed-chain row alone.
|
||||||
|
- `M-1a-3` `TextInput` selection back below the intercept return →
|
||||||
|
the intercepting-producer row alone.
|
||||||
|
- `M-1a-4` typed text through `encode_paste` → A8, with the forbidden
|
||||||
|
bytes at the PTY.
|
||||||
|
- `M-1a-5` inbound gate disabled → the v23 row, with `REFUSED` inside
|
||||||
|
the CRDT op.
|
||||||
|
- `M-1a-6b` a variant inserted **before** `PanelPointer` → the
|
||||||
|
frozen-byte pin, discriminant 15 → 16.
|
||||||
|
- **`M-1a-6` (the first attempt) was a WRONG MUTATION, not a vacuous
|
||||||
|
pin**, and is recorded because the failure mode is instructive: the
|
||||||
|
wedge went in *after* `PanelPointer` — where an append belongs — so
|
||||||
|
nothing shifted and the pin passed, correctly. A mutation aimed at the
|
||||||
|
wrong side of a boundary reports a sound pin as worthless.
|
||||||
|
|
||||||
## GUI arc Stage 1 — 1-pre MERGED as #237 (`d038f71`); 1a is next, NOT STARTED
|
## GUI arc Stage 1 — 1-pre MERGED as #237 (`d038f71`); 1a is next, NOT STARTED
|
||||||
|
|
||||||
**The lane is rewritten, not removed.** Rule 4 removes a lane when its
|
**The lane is rewritten, not removed.** Rule 4 removes a lane when its
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,108 @@
|
||||||
# GUI arc, Stage 1 — input foundation (framing)
|
# GUI arc, Stage 1 — input foundation (framing)
|
||||||
|
|
||||||
**Status: revision 11 — APPROVED.** Revisions 1–8 rejected; revision 9
|
**Status: revision 12 — APPROVED.** Revision 12 is §2's ground-truth
|
||||||
|
re-measurement for Stage 1a and changes no ruling; it carries two
|
||||||
|
corrections to claims that were wrong at the original anchor too.
|
||||||
|
|
||||||
|
**Previously, revision 11 — APPROVED.** Revisions 1–8 rejected; revision 9
|
||||||
is the approved design. Revision 10 recorded a scope correction found
|
is the approved design. Revision 10 recorded a scope correction found
|
||||||
against the 1-pre implementation and **also made a claim about P2 that
|
against the 1-pre implementation and **also made a claim about P2 that
|
||||||
review overturned; revision 11 retracts it and P2 is implemented as
|
review overturned; revision 11 retracts it and P2 is implemented as
|
||||||
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
|
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
|
||||||
IMPLEMENTED**; 1a onward may begin from this document.
|
IMPLEMENTED**; 1a onward may begin from this document.
|
||||||
|
|
||||||
**Verification base:** checked in the `gui-arc-stage0` worktree at
|
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
|
||||||
`a994f37`, whose tree for these files is what `f8ad3e7` merged.
|
the tip after 1-pre; it was originally taken at `a994f37`. Sections
|
||||||
|
other than §2 were written against `a994f37` and their *rulings* are
|
||||||
|
unaffected by 1-pre, which changed no behaviour — but **any line number
|
||||||
|
outside §2 predates 1-pre and should be re-checked before it is relied
|
||||||
|
on.**
|
||||||
|
|
||||||
## 1. What this stage closes
|
## 1. What this stage closes
|
||||||
|
|
||||||
Journey **step 5**. **Not step 12** (Stage 4b, P2-gated). Five of nine
|
Journey **step 5**. **Not step 12** (Stage 4b, P2-gated). Five of nine
|
||||||
§3.1 blockers die here.
|
§3.1 blockers die here.
|
||||||
|
|
||||||
## 2. Ground truth at `a994f37`
|
## 2. Ground truth — RE-MEASURED at `4f77491` (2026-08-12)
|
||||||
|
|
||||||
`App::window_event` (`main.rs:2734`) is **655 lines**; **eight**
|
**Originally taken at `a994f37`, before 1-pre.** 1-pre (#237) moved
|
||||||
`WindowEvent` arms handled, the rest fall to `_`.
|
almost every GPU-side coordinate below, so the section is re-measured
|
||||||
|
rather than left to rot — **a framing whose ground truth points at the
|
||||||
|
wrong lines is how an implementation ends up arguing with the tree**.
|
||||||
|
Two claims were *wrong at both anchors* and are corrected, not merely
|
||||||
|
renumbered; they are marked **CORRECTION**.
|
||||||
|
|
||||||
**Two text producers.** `translate_key(logical: &Key, …)`
|
### Still true, re-checked
|
||||||
(`main.rs:10975`) reads the **logical key** and truncates via
|
|
||||||
`chars().next()`; **`KeyEvent.text` is never read.**
|
|
||||||
`WindowEvent::Ime::Commit(String)` is a **separate event, ignored
|
|
||||||
entirely** (`set_ime_allowed`/`WindowEvent::Ime`: zero occurrences).
|
|
||||||
**Today's two failures are therefore: multi-scalar keyboard input is
|
|
||||||
truncated to its first scalar, and an IME commit produces nothing.**
|
|
||||||
|
|
||||||
**`FrontendEvent`: sixteen variants, none carrying an open path or
|
- **`FrontendEvent`: sixteen variants**, none carrying an open path or
|
||||||
command invocation.** `PROTOCOL_VERSION = 23`.
|
command invocation. **`PROTOCOL_VERSION = 23`.**
|
||||||
|
- **`WindowEvent::Ime` is ignored entirely** —
|
||||||
|
`set_ime_allowed`/`WindowEvent::Ime` still **zero occurrences**, so
|
||||||
|
`Ime::Commit(String)` produces nothing. (1d's D1.)
|
||||||
|
- **TUI wheel arms unmoved**: `EditorState::dispatch_mouse`
|
||||||
|
(`src/editor.rs:3052`), `ScrollUp`/`ScrollDown` at **`:3203`** — 1-pre
|
||||||
|
touched only `pmacs-gpu`.
|
||||||
|
- **The handshake precedes any window**: the client is constructed and
|
||||||
|
the handshake done before `run_app` (connect `main.rs:702`, `run_app`
|
||||||
|
`main.rs:733`; the old citation `:696` was the enclosing block).
|
||||||
|
- **1c is producer-side only for Focus/Detach** — those variants exist
|
||||||
|
on the wire. **Title, Bell and `Goodbye` are GPU consumer work.**
|
||||||
|
- **`Outbox::enqueue` returns `false` once closed** and **coalesces by
|
||||||
|
kind** (`attach.rs:415`; the old `:414` was off by one at both
|
||||||
|
anchors).
|
||||||
|
|
||||||
**The handshake precedes any window** — `EventLoop` built, client
|
### Moved by 1-pre
|
||||||
constructed, handshake done **before `run_app`** (`main.rs:696`).
|
|
||||||
|
|
||||||
**TUI wheel arms**: `EditorState::dispatch_mouse` (`src/editor.rs:3052`),
|
- **`App::window_event` is `main.rs:4450` and is FOUR lines**, not 655
|
||||||
`ScrollUp`/`ScrollDown` at **`:3203`**.
|
at `:2734`. It calls `dispatch_window_event` and performs the exit;
|
||||||
|
**routing lives in `route_event`, and the bodies in seven `apply_*`
|
||||||
|
methods.**
|
||||||
|
- **"Eight arms handled, the rest fall to `_`" is now three family
|
||||||
|
decision functions** — `route_lifecycle`, `route_keyboard` (plus
|
||||||
|
`route_key_action`), `route_pointer` — over **nine** named
|
||||||
|
`WindowEvent` variants, with `Route::Unrouted` as the wildcard. **1a
|
||||||
|
edits `apply_keyboard` and `translate_key`, not `window_event`.**
|
||||||
|
- **`translate_key(logical: &Key, …)` is `main.rs:12053`**, not
|
||||||
|
`:10975`. It still reads the **logical key** and still truncates via
|
||||||
|
`chars().next()`, and `_ => return None` is still there — so **A1's
|
||||||
|
witness holds**.
|
||||||
|
|
||||||
**1c is producer-side only for Focus/Detach** — those variants exist on
|
### CORRECTION 1 — `KeyEvent.text` IS read, and always was
|
||||||
the wire. **Title, Bell and `Goodbye` are GPU consumer work.**
|
|
||||||
|
|
||||||
**`Outbox::enqueue` returns `false` once closed** (`attach.rs:414`) and
|
The original section said *"`KeyEvent.text` is never read."* **That is
|
||||||
**coalesces by kind**.
|
false, and was false at `a994f37` too** (`:2800` there, `main.rs:3251`
|
||||||
|
now): the AltGr rule reads it, as `is_layout_text(key.text.as_deref(),
|
||||||
|
pmods)`.
|
||||||
|
|
||||||
|
The claim the section meant, and which is true: **`KeyEvent.text` is
|
||||||
|
never read as the text a keypress INSERTS.** It is consulted only as a
|
||||||
|
*discriminator* — Ctrl+Alt plus printable text means AltGr rather than a
|
||||||
|
command chord — and the inserted character always comes from
|
||||||
|
`translate_key`'s logical key, truncated to one scalar.
|
||||||
|
|
||||||
|
**This matters to A5, not just to accuracy.** §5's rule 2 exempts
|
||||||
|
"printable Ctrl+Alt recognized by the existing AltGr rule", so the
|
||||||
|
precedence table already depends on the code the section claimed did not
|
||||||
|
exist. **1a widens `text` from a discriminator to a payload**, and that
|
||||||
|
is the actual change of kind — stating it as "text is never read" hides
|
||||||
|
the one place the new payload must not disturb.
|
||||||
|
|
||||||
|
### CORRECTION 2 — A4's exit site
|
||||||
|
|
||||||
|
A4's witness cited *"exits (`main.rs:2771`)"*. 1-pre moved the mechanism
|
||||||
|
without changing the behaviour: an idle Escape still exits, but
|
||||||
|
`apply_keyboard` (`main.rs:3219`) now returns `EventOutcome::Exit` and
|
||||||
|
**`window_event` (`main.rs:4452`) performs the only executable
|
||||||
|
`event_loop.exit()` in the crate.**
|
||||||
|
|
||||||
|
**A4 therefore deletes a branch in `apply_keyboard` and changes its
|
||||||
|
return type — it does not touch `window_event`.** And **`EventOutcome`
|
||||||
|
survives A4**: a native close still returns `Exit`, and
|
||||||
|
`dispatch_window_event` must still distinguish it from `Continue`.
|
||||||
|
|
||||||
|
**Today's two failures are unchanged by any of this:** multi-scalar
|
||||||
|
keyboard input is truncated to its first scalar, and an IME commit
|
||||||
|
produces nothing.
|
||||||
|
|
||||||
## 3. PR topology
|
## 3. PR topology
|
||||||
|
|
||||||
|
|
@ -229,7 +290,7 @@ The crate has **exactly one** executable `event_loop.exit()`, in
|
||||||
| A1 | `F1`–`F35` → `F(1..=35)` | `_ => return None` | map `F13+` → `None` → F13–F35 rows |
|
| A1 | `F1`–`F35` → `F(1..=35)` | `_ => return None` | map `F13+` → `None` → F13–F35 rows |
|
||||||
| A2 | Shift+Tab → `BackTab` with `Shift` set | produces `Tab` | drop `Shift` → A2 only |
|
| A2 | Shift+Tab → `BackTab` with `Shift` set | produces `Tab` | drop `Shift` → A2 only |
|
||||||
| A3 | `ContextMenu` → `Menu` | produces nothing | map to `Char('\0')` → A3 only |
|
| A3 | `ContextMenu` → `Menu` | produces nothing | map to `Char('\0')` → A3 only |
|
||||||
| A4 | Idle Escape reaches the daemon, never exits | exits (`main.rs:2771`) | restore the quit branch → A4 only |
|
| A4 | Idle Escape reaches the daemon, never exits | exits — `apply_keyboard` (`main.rs:3219`) returns `EventOutcome::Exit`, performed at `main.rs:4452` | restore the quit branch → A4 only |
|
||||||
| A5 | Precedence per §5 (1–8) | multi-scalar truncated; IME ignored | move rule 1 (control text → text) → the `Enter`-in-dired row |
|
| A5 | Precedence per §5 (1–8) | multi-scalar truncated; IME ignored | move rule 1 (control text → text) → the `Enter`-in-dired row |
|
||||||
| A6 | One commit = one edit, undo unit, hook, eligible CRDT op | commit truncated to one scalar | one edit per scalar → undo-unit row (**and D3 surfaces here**) |
|
| A6 | One commit = one edit, undo unit, hook, eligible CRDT op | commit truncated to one scalar | one edit per scalar → undo-unit row (**and D3 surfaces here**) |
|
||||||
| A7 | Prompts consume scalars **in order** | multi-scalar never arrives | reverse order → A7's prompt transcript |
|
| A7 | Prompts consume scalars **in order** | multi-scalar never arrives | reverse order → A7's prompt transcript |
|
||||||
|
|
|
||||||
|
|
@ -960,6 +960,36 @@ impl AttachClient {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a `FrontendEvent::TextInput` (GUI arc 1a): text the user
|
||||||
|
/// committed, by keypress or IME.
|
||||||
|
///
|
||||||
|
/// **The caller must gate on [`Self::session_protocol_version`]
|
||||||
|
/// `>= TEXT_INPUT_MIN_VERSION` and fall back to `Key`** — this
|
||||||
|
/// method does not check, because the fallback needs the untranslated
|
||||||
|
/// key and only the caller has it. Withholding is the whole
|
||||||
|
/// old-peer contract: a `< 24` daemon keeps the behaviour it has,
|
||||||
|
/// including today's first-scalar truncation.
|
||||||
|
///
|
||||||
|
/// Rejects oversize here as well as at the daemon, so a payload
|
||||||
|
/// that could never be accepted is not written to the socket at
|
||||||
|
/// all; rejection rather than truncation, per `TEXT_INPUT_MAX_BYTES`.
|
||||||
|
pub fn send_text_input(&self, text: &str) -> Result<(), TransportError> {
|
||||||
|
if text.len() > pmacs_protocol::TEXT_INPUT_MAX_BYTES {
|
||||||
|
return Err(TransportError::Io(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!(
|
||||||
|
"TextInput of {} bytes exceeds the {}-byte cap",
|
||||||
|
text.len(),
|
||||||
|
pmacs_protocol::TEXT_INPUT_MAX_BYTES
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.send_event(FrontendEvent::TextInput {
|
||||||
|
frontend_id: self.frontend_id,
|
||||||
|
text: text.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
|
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
|
||||||
/// hit-tested gesture in source bytes. Callers gate on
|
/// hit-tested gesture in source bytes. Callers gate on
|
||||||
/// [`Self::session_protocol_version`] `>= 5`.
|
/// [`Self::session_protocol_version`] `>= 5`.
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ use pmacs_protocol::{
|
||||||
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES,
|
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES,
|
||||||
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers,
|
MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers,
|
||||||
MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind,
|
MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind,
|
||||||
SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame,
|
SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS,
|
||||||
UnderlineStyle,
|
TEXT_INPUT_MIN_VERSION, TerminalFrame, UnderlineStyle,
|
||||||
cell::{Color as CellColor, Style as CellStyle},
|
cell::{Color as CellColor, Style as CellStyle},
|
||||||
is_builtin_pair_char, is_modeline_face_name,
|
is_builtin_pair_char, is_modeline_face_name,
|
||||||
panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload},
|
panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload},
|
||||||
|
|
@ -3162,7 +3162,7 @@ impl App {
|
||||||
Route::Keyboard {
|
Route::Keyboard {
|
||||||
action: KeyAction::Press,
|
action: KeyAction::Press,
|
||||||
key,
|
key,
|
||||||
} => return self.apply_keyboard(key),
|
} => self.apply_keyboard(&key.logical_key, key.text.as_deref()),
|
||||||
Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y),
|
Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y),
|
||||||
Route::Pointer(PointerRoute::Left(button_state)) => {
|
Route::Pointer(PointerRoute::Left(button_state)) => {
|
||||||
self.apply_left_button(button_state);
|
self.apply_left_button(button_state);
|
||||||
|
|
@ -3183,10 +3183,25 @@ impl App {
|
||||||
EventOutcome::Continue
|
EventOutcome::Continue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform [`KeyAction::Press`]. The router has already
|
/// Perform [`KeyAction::Press`]. The router has already discarded
|
||||||
/// discarded key-ups, so `key` is always a press.
|
/// key-ups, so this is always a press.
|
||||||
|
///
|
||||||
|
/// **Takes the two fields it reads rather than the whole
|
||||||
|
/// `KeyEvent`, deliberately.** `KeyEvent` carries a `pub(crate)`
|
||||||
|
/// field and cannot be constructed outside winit, so a body taking
|
||||||
|
/// one is undrivable by any test; `Key` and `&str` are ordinary
|
||||||
|
/// values. That distinction is not academic — 1a's first review
|
||||||
|
/// found the `TextInput` selection sited *below* the intercept
|
||||||
|
/// return, making A7 and A8 reachable only when no prompt and no
|
||||||
|
/// terminal were present. The classifier was correct throughout;
|
||||||
|
/// only the call site was wrong, so only a test that drives THIS
|
||||||
|
/// function could have caught it.
|
||||||
|
///
|
||||||
|
/// The router arm remains unwitnessable — it still cannot be handed
|
||||||
|
/// a `WindowEvent::KeyboardInput` — so 1-pre's structural exception
|
||||||
|
/// narrows to that one pattern arm rather than disappearing.
|
||||||
#[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order.
|
#[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order.
|
||||||
fn apply_keyboard(&mut self, key: &KeyEvent) -> EventOutcome {
|
fn apply_keyboard(&mut self, logical: &Key, text: Option<&str>) {
|
||||||
// While the daemon is intercepting keystrokes — an active
|
// While the daemon is intercepting keystrokes — an active
|
||||||
// incremental search (Q#SR5), or a minibuffer / pending
|
// incremental search (Q#SR5), or a minibuffer / pending
|
||||||
// prefix — every key belongs to its handler, not the
|
// prefix — every key belongs to its handler, not the
|
||||||
|
|
@ -3213,29 +3228,33 @@ impl App {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(State::completion_open_for_current_buffer);
|
.is_some_and(State::completion_open_for_current_buffer);
|
||||||
|
|
||||||
// Escape cancels an active intercept (e.g. a running
|
// A4 / Q#S1-1 — **every** Escape reaches the daemon, and none
|
||||||
// search) or dismisses the completion popup; otherwise it
|
// exits.
|
||||||
// stays the local quit.
|
//
|
||||||
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
|
// It used to quit the frontend when nothing was intercepting,
|
||||||
if intercept || completion_open {
|
// which made the GUI's most common "get me out of this" key
|
||||||
if let Some(client) = self.attach_client.as_ref()
|
// destroy the window instead of cancelling. Q#S1-1 settles the
|
||||||
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
|
// exits and Escape is not among them: a native close detaches
|
||||||
{
|
// this frontend, `editor.quit` shuts the daemon and its
|
||||||
eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}");
|
// attachments down, and **Escape only cancels or round-trips**.
|
||||||
}
|
//
|
||||||
} else {
|
// The `intercept || completion_open` test went with the quit
|
||||||
// Q#S1-1 / A4 — the local quit, unchanged here and
|
// branch. It never decided what to SEND — both arms sent the
|
||||||
// deleted by Stage 1a: an idle Escape must reach the
|
// same `Escape` — only whether to send at all, so with one
|
||||||
// daemon. `window_event` performs the exit. This is the
|
// behaviour left there is nothing for it to choose. (Both flags
|
||||||
// only reason a BODY needs an outcome; `EventOutcome`
|
// remain live below, for the OS-paste, round-trip and
|
||||||
// itself outlives A4, since a native close still exits.
|
// completion-accept paths.)
|
||||||
return EventOutcome::Exit;
|
if matches!(*logical, Key::Named(NamedKey::Escape)) {
|
||||||
|
if let Some(client) = self.attach_client.as_ref()
|
||||||
|
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
|
||||||
|
{
|
||||||
|
eprintln!("pmacs-gpu: send Escape failed: {e}");
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else {
|
let Some((pkey, mut pmods)) = translate_key(logical, self.modifiers) else {
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// AltGr / international text (audit F-004). winit reports
|
// AltGr / international text (audit F-004). winit reports
|
||||||
|
|
@ -3248,7 +3267,7 @@ impl App {
|
||||||
// being routed to the keymap. Alt alone is left intact so
|
// being routed to the keymap. Alt alone is left intact so
|
||||||
// macOS Option-as-Meta still reaches the keymap; on layouts
|
// macOS Option-as-Meta still reaches the keymap; on layouts
|
||||||
// where AltGr isn't Ctrl+Alt this is a no-op.
|
// where AltGr isn't Ctrl+Alt this is a no-op.
|
||||||
if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(key.text.as_deref(), pmods) {
|
if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(text, pmods) {
|
||||||
pmods = if pmods.contains(Modifiers::SHIFT) {
|
pmods = if pmods.contains(Modifiers::SHIFT) {
|
||||||
Modifiers::SHIFT
|
Modifiers::SHIFT
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3271,13 +3290,57 @@ impl App {
|
||||||
{
|
{
|
||||||
eprintln!("pmacs-gpu: send_paste failed: {e}");
|
eprintln!("pmacs-gpu: send_paste failed: {e}");
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(client) = self.attach_client.as_ref() else {
|
let Some(client) = self.attach_client.as_ref() else {
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A5 — multi-scalar text travels as ONE `TextInput`, if the
|
||||||
|
// session can carry it.
|
||||||
|
//
|
||||||
|
// **This MUST precede the intercept branch below, and that
|
||||||
|
// placement is the contract rather than a preference.** A
|
||||||
|
// modal prompt or a focused terminal is exactly what makes
|
||||||
|
// `daemon_intercepts_keys` true, so classifying after it would
|
||||||
|
// leave A7 (prompts consume scalars in order) and A8 (terminals
|
||||||
|
// take raw UTF-8) reachable only when neither a prompt nor a
|
||||||
|
// terminal is present — which is to say, never. Sited here, the
|
||||||
|
// producer sends the same `TextInput` in every state and the
|
||||||
|
// daemon's `dispatch_text_input` applies §5's modal precedence,
|
||||||
|
// which is where that decision belongs: the frontend cannot see
|
||||||
|
// which shadow is up.
|
||||||
|
//
|
||||||
|
// Ordering against the branches below is safe by construction,
|
||||||
|
// not by luck: `text_input_payload` returns `None` whenever a
|
||||||
|
// command modifier is held, so the Ctrl-V paste and
|
||||||
|
// command-chord paths can never be shadowed by it.
|
||||||
|
//
|
||||||
|
// **The version gate WITHHOLDS rather than degrades.** A `< 24`
|
||||||
|
// daemon keeps exactly the behaviour it has — including today's
|
||||||
|
// truncation to the first scalar — because the fallback is the
|
||||||
|
// unchanged `Key` path below. No regression, not retroactive
|
||||||
|
// correctness.
|
||||||
|
if let Some(text) = text_input_payload(logical, text, pmods)
|
||||||
|
&& client.session_protocol_version() >= TEXT_INPUT_MIN_VERSION
|
||||||
|
{
|
||||||
|
if let Some(state) = self.state.as_mut() {
|
||||||
|
state.mark_cursor_stale_after_round_trip();
|
||||||
|
}
|
||||||
|
if debug_input() {
|
||||||
|
eprintln!(
|
||||||
|
"pmacs-gpu send_text_input: {} scalars",
|
||||||
|
text.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let client = self.attach_client.as_ref().expect("client checked above");
|
||||||
|
if let Err(e) = client.send_text_input(text) {
|
||||||
|
eprintln!("pmacs-gpu: send_text_input failed: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Intercept path: round-trip every key into the daemon's
|
// Intercept path: round-trip every key into the daemon's
|
||||||
// active handler (search query / step / accept / cancel).
|
// active handler (search query / step / accept / cancel).
|
||||||
if intercept {
|
if intercept {
|
||||||
|
|
@ -3290,7 +3353,7 @@ impl App {
|
||||||
if let Err(e) = client.send_key(pkey, pmods) {
|
if let Err(e) = client.send_key(pkey, pmods) {
|
||||||
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
|
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle: forward any command chord (Char/Enter/Tab with
|
// Idle: forward any command chord (Char/Enter/Tab with
|
||||||
|
|
@ -3311,7 +3374,7 @@ impl App {
|
||||||
if let Err(e) = client.send_key(pkey, pmods) {
|
if let Err(e) = client.send_key(pkey, pmods) {
|
||||||
eprintln!("pmacs-gpu: send_key (command chord) failed: {e}");
|
eprintln!("pmacs-gpu: send_key (command chord) failed: {e}");
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session B2 forwards cursor motion + plain text editing
|
// Session B2 forwards cursor motion + plain text editing
|
||||||
|
|
@ -3320,7 +3383,7 @@ impl App {
|
||||||
// here and are withheld, leaving OS/WM shortcuts (Cmd-Q,
|
// here and are withheld, leaving OS/WM shortcuts (Cmd-Q,
|
||||||
// Cmd-C) to the platform.
|
// Cmd-C) to the platform.
|
||||||
if !should_forward_key(pkey, pmods) {
|
if !should_forward_key(pkey, pmods) {
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
|
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
|
||||||
|
|
@ -3357,7 +3420,7 @@ impl App {
|
||||||
{
|
{
|
||||||
eprintln!("pmacs-gpu: send Viewport failed: {e}");
|
eprintln!("pmacs-gpu: send Viewport failed: {e}");
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(state) = self.state.as_mut() {
|
if let Some(state) = self.state.as_mut() {
|
||||||
if state.defer_round_trip_key_if_needed(pkey, pmods) {
|
if state.defer_round_trip_key_if_needed(pkey, pmods) {
|
||||||
|
|
@ -3367,7 +3430,7 @@ impl App {
|
||||||
pending optimistic cursor"
|
pending optimistic cursor"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return EventOutcome::Continue;
|
return;
|
||||||
}
|
}
|
||||||
state.mark_cursor_stale_after_round_trip();
|
state.mark_cursor_stale_after_round_trip();
|
||||||
}
|
}
|
||||||
|
|
@ -3377,7 +3440,6 @@ impl App {
|
||||||
if let Err(e) = client.send_key(pkey, pmods) {
|
if let Err(e) = client.send_key(pkey, pmods) {
|
||||||
eprintln!("pmacs-gpu: send_key failed: {e}");
|
eprintln!("pmacs-gpu: send_key failed: {e}");
|
||||||
}
|
}
|
||||||
EventOutcome::Continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3418,23 +3480,19 @@ enum Route<'a> {
|
||||||
|
|
||||||
/// What the event loop must do once a family's body has run.
|
/// What the event loop must do once a family's body has run.
|
||||||
///
|
///
|
||||||
/// **Two producers, and they are not the same kind of thing.**
|
/// **Since A4, `LifecycleRoute::Exit` — a native window close — is the
|
||||||
/// `LifecycleRoute::Exit` is a native window close, which must always
|
/// SOLE producer.** `apply_keyboard` used to be the second, for the
|
||||||
/// exit; `apply_keyboard` returns `Exit` for an idle Escape, which is a
|
/// idle-Escape local quit; A4 deleted that branch and with it the
|
||||||
/// local quit. Returning the decision rather than taking an
|
/// keyboard body's need to return anything, so it returns `()` and the
|
||||||
/// `&ActiveEventLoop` is what keeps every body reachable from a test:
|
/// obsolete channel is gone rather than merely unused.
|
||||||
/// the crate has **exactly one** executable `event_loop.exit()`, in
|
|
||||||
/// `window_event`.
|
|
||||||
///
|
///
|
||||||
/// **Stage 1a's A4 removes the KEYBOARD producer only** — an idle
|
/// **One producer is not one variant.** The type stays because
|
||||||
/// Escape must reach the daemon and never exit — leaving **one** `Exit`
|
|
||||||
/// producer, the native close.
|
|
||||||
///
|
|
||||||
/// **One producer is not one variant.** This type survives A4 because
|
|
||||||
/// `dispatch_window_event` must still distinguish `Continue` from
|
/// `dispatch_window_event` must still distinguish `Continue` from
|
||||||
/// `Exit` on every event it handles: nearly all of them must not exit,
|
/// `Exit` on every event it handles: nearly all must not exit, and the
|
||||||
/// and the close must. What A4 changes is `apply_keyboard`'s signature,
|
/// close must. Returning the decision rather than taking an
|
||||||
/// not this type.
|
/// `&ActiveEventLoop` is also what keeps the bodies reachable from a
|
||||||
|
/// test — the crate has exactly one executable `event_loop.exit()`, in
|
||||||
|
/// `window_event`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum EventOutcome {
|
enum EventOutcome {
|
||||||
Continue,
|
Continue,
|
||||||
|
|
@ -3792,12 +3850,42 @@ impl EffectHarness {
|
||||||
harness
|
harness
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive `apply_keyboard` directly and report what it produced.
|
||||||
|
///
|
||||||
|
/// Bypasses `route_event` because a `WindowEvent::KeyboardInput`
|
||||||
|
/// cannot be constructed outside winit — 1-pre's recorded
|
||||||
|
/// structural exception. What that exception covers is the router's
|
||||||
|
/// pattern arm; the BODY is reachable, and the body is where 1a's
|
||||||
|
/// placement defect lived.
|
||||||
|
fn feed_keyboard(&mut self, logical: &Key, text: Option<&str>) -> Step {
|
||||||
|
let before = self.snapshot();
|
||||||
|
self.app.apply_keyboard(logical, text);
|
||||||
|
let after = self.snapshot();
|
||||||
|
Step {
|
||||||
|
local: Self::diff(&before, &after, EventOutcome::Continue),
|
||||||
|
outbound: self.read_until_sentinel(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch one event and report everything it did.
|
/// Dispatch one event and report everything it did.
|
||||||
fn feed(&mut self, event: &WindowEvent) -> Step {
|
fn feed(&mut self, event: &WindowEvent) -> Step {
|
||||||
let before = self.snapshot();
|
let before = self.snapshot();
|
||||||
let outcome = self.app.dispatch_window_event(event);
|
let outcome = self.app.dispatch_window_event(event);
|
||||||
let after = self.snapshot();
|
let after = self.snapshot();
|
||||||
|
|
||||||
|
Step {
|
||||||
|
local: Self::diff(&before, &after, outcome),
|
||||||
|
outbound: self.read_until_sentinel(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local effects between two snapshots. Shared by every entry
|
||||||
|
/// point so a new one cannot observe a different set by accident.
|
||||||
|
fn diff(
|
||||||
|
before: &EffectSnapshot,
|
||||||
|
after: &EffectSnapshot,
|
||||||
|
outcome: EventOutcome,
|
||||||
|
) -> Vec<LocalEffect> {
|
||||||
let mut local = Vec::new();
|
let mut local = Vec::new();
|
||||||
if outcome == EventOutcome::Exit {
|
if outcome == EventOutcome::Exit {
|
||||||
local.push(LocalEffect::Exit);
|
local.push(LocalEffect::Exit);
|
||||||
|
|
@ -3819,11 +3907,7 @@ impl EffectHarness {
|
||||||
top: after.scroll_top,
|
top: after.scroll_top,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
local
|
||||||
Step {
|
|
||||||
local,
|
|
||||||
outbound: self.read_until_sentinel(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observable state the local effects are derived from.
|
/// Observable state the local effects are derived from.
|
||||||
|
|
@ -4350,6 +4434,118 @@ mod input_routing_tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// GUI arc 1a — PRODUCER REACHABILITY.
|
||||||
|
//
|
||||||
|
// These drive `App::apply_keyboard`, the real call site, not
|
||||||
|
// `text_input_payload`. 1a's first review found the classifier
|
||||||
|
// correct and the call site wrong: `TextInput` selection sat below
|
||||||
|
// the intercept return, and a modal prompt or a focused terminal is
|
||||||
|
// exactly what makes intercept true, so A7 and A8 were reachable
|
||||||
|
// only when neither was present. A classifier test stays green
|
||||||
|
// through that defect; these rows do not.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Multi-scalar text reaches the wire as `TextInput` **while the
|
||||||
|
/// daemon is intercepting** — the state a modal prompt puts the
|
||||||
|
/// session in.
|
||||||
|
#[test]
|
||||||
|
fn multi_scalar_text_is_sent_while_the_daemon_intercepts() {
|
||||||
|
let mut h = EffectHarness::new();
|
||||||
|
// A minibuffer is up: `daemon_intercepts_keys` is true.
|
||||||
|
h.app.state.as_mut().expect("harness state").dispatch_idle = false;
|
||||||
|
|
||||||
|
let step = h.feed_keyboard(&Key::Character("e\u{301}".into()), Some("e\u{301}"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
step.outbound.iter().any(|e| matches!(
|
||||||
|
e,
|
||||||
|
pmacs_protocol::FrontendEvent::TextInput { text, .. } if text == "e\u{301}"
|
||||||
|
)),
|
||||||
|
"an intercepting session must still get the whole commit, \
|
||||||
|
not a truncated Key; got {:?}",
|
||||||
|
step.outbound
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!step
|
||||||
|
.outbound
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))),
|
||||||
|
"and must NOT also get the first-scalar Key: {:?}",
|
||||||
|
step.outbound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A4 — an IDLE Escape reaches the daemon and does not exit.
|
||||||
|
///
|
||||||
|
/// "Idle" is the case that used to quit: with nothing intercepting,
|
||||||
|
/// Escape destroyed the window instead of cancelling. Both halves
|
||||||
|
/// are asserted, because either alone would pass a wrong
|
||||||
|
/// implementation — sending Escape while also exiting, or not
|
||||||
|
/// exiting while also sending nothing.
|
||||||
|
#[test]
|
||||||
|
fn a4_an_idle_escape_reaches_the_daemon_and_does_not_exit() {
|
||||||
|
let mut h = EffectHarness::new();
|
||||||
|
// Establish idle explicitly. A fresh `State` starts with
|
||||||
|
// `dispatch_idle` false — the daemon has not said otherwise yet
|
||||||
|
// — so ASSERTING the precondition rather than setting it would
|
||||||
|
// have tested the intercepting case under an idle name. It
|
||||||
|
// failed exactly that way first.
|
||||||
|
h.app.state.as_mut().expect("harness state").dispatch_idle = true;
|
||||||
|
assert!(
|
||||||
|
!h.app
|
||||||
|
.state
|
||||||
|
.as_ref()
|
||||||
|
.expect("harness state")
|
||||||
|
.daemon_intercepts_keys(),
|
||||||
|
"precondition: nothing intercepts, which is the case that quit"
|
||||||
|
);
|
||||||
|
|
||||||
|
let step = h.feed_keyboard(&Key::Named(NamedKey::Escape), None);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
step.outbound.iter().any(|e| matches!(
|
||||||
|
e,
|
||||||
|
pmacs_protocol::FrontendEvent::Key(k) if k.key == ProtocolKey::Escape
|
||||||
|
)),
|
||||||
|
"an idle Escape must reach the daemon: {:?}",
|
||||||
|
step.outbound
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!step.local.contains(&LocalEffect::Exit),
|
||||||
|
"and must NOT exit: {:?}",
|
||||||
|
step.local
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complement, so the row above cannot pass by sending
|
||||||
|
/// `TextInput` for everything: a SINGLE scalar while intercepting
|
||||||
|
/// still travels as `Key`, which is §5 rule 4 and preserves mode
|
||||||
|
/// keymaps and typed provenance.
|
||||||
|
#[test]
|
||||||
|
fn single_scalar_text_still_travels_as_key_while_intercepting() {
|
||||||
|
let mut h = EffectHarness::new();
|
||||||
|
h.app.state.as_mut().expect("harness state").dispatch_idle = false;
|
||||||
|
|
||||||
|
let step = h.feed_keyboard(&Key::Character("a".into()), Some("a"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
step.outbound
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))),
|
||||||
|
"a single scalar stays a Key: {:?}",
|
||||||
|
step.outbound
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!step
|
||||||
|
.outbound
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, pmacs_protocol::FrontendEvent::TextInput { .. })),
|
||||||
|
"and must not become TextInput: {:?}",
|
||||||
|
step.outbound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// P2 — the harness records a transcript, and the transcript
|
/// P2 — the harness records a transcript, and the transcript
|
||||||
/// distinguishes every routed effect from the others and from an
|
/// distinguishes every routed effect from the others and from an
|
||||||
/// unclaimed event. **Two of these rows produce no outbound traffic
|
/// unclaimed event. **Two of these rows produce no outbound traffic
|
||||||
|
|
@ -12070,9 +12266,26 @@ fn translate_key(
|
||||||
NamedKey::Enter => ProtocolKey::Enter,
|
NamedKey::Enter => ProtocolKey::Enter,
|
||||||
NamedKey::Delete => ProtocolKey::Delete,
|
NamedKey::Delete => ProtocolKey::Delete,
|
||||||
NamedKey::Insert => ProtocolKey::Insert,
|
NamedKey::Insert => ProtocolKey::Insert,
|
||||||
|
// A2 — Shift+Tab is `BackTab`, a key of its own, and the
|
||||||
|
// Shift stays set. The daemon's keymap binds the two
|
||||||
|
// differently (indent versus outdent), and a `Tab` that
|
||||||
|
// merely carries Shift is indistinguishable from a Tab the
|
||||||
|
// user shifted by accident. The TUI has always sent
|
||||||
|
// `BackTab`; this closes the divergence rather than
|
||||||
|
// inventing a convention.
|
||||||
|
NamedKey::Tab if mods.shift_key() => ProtocolKey::BackTab,
|
||||||
NamedKey::Tab => ProtocolKey::Tab,
|
NamedKey::Tab => ProtocolKey::Tab,
|
||||||
NamedKey::Space => ProtocolKey::Char(' '),
|
NamedKey::Space => ProtocolKey::Char(' '),
|
||||||
_ => return None,
|
// A3 — the menu key. `ProtocolKey::Menu` already exists and
|
||||||
|
// the TUI already sends it; only the GPU translation was
|
||||||
|
// missing, so the key did nothing in the GUI.
|
||||||
|
NamedKey::ContextMenu => ProtocolKey::Menu,
|
||||||
|
// A1 — F1..=F35. winit names each as its own variant, so
|
||||||
|
// there is no arithmetic to do and no range to trust: the
|
||||||
|
// mapping is total over the variants winit defines, and
|
||||||
|
// `named_function_key` is exhaustive rather than a
|
||||||
|
// computed offset.
|
||||||
|
named => named_function_key(*named).map(ProtocolKey::F)?,
|
||||||
},
|
},
|
||||||
Key::Character(s) => ProtocolKey::Char(s.chars().next()?),
|
Key::Character(s) => ProtocolKey::Char(s.chars().next()?),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
|
|
@ -12080,6 +12293,112 @@ fn translate_key(
|
||||||
Some((pkey, pmods))
|
Some((pkey, pmods))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A5 / Q#S1-9 — whether a keypress should travel as `TextInput`
|
||||||
|
/// rather than `Key`, and with what payload.
|
||||||
|
///
|
||||||
|
/// **A keypress stays `Key` unless a rule below moves it.** That
|
||||||
|
/// default is the contract, not an implementation convenience: every
|
||||||
|
/// mode keymap, every command chord and today's typed provenance are
|
||||||
|
/// built on `Key`, so widening the exception is how a working binding
|
||||||
|
/// silently becomes an insert.
|
||||||
|
///
|
||||||
|
/// The rules, in the order they are checked:
|
||||||
|
///
|
||||||
|
/// 1. **Named keys and control text stay `Key`**, whatever
|
||||||
|
/// `KeyEvent.text` says — `Enter` reports `"\r"`, and an `Enter`
|
||||||
|
/// that arrived as text would insert a newline in dired instead of
|
||||||
|
/// opening a file.
|
||||||
|
/// 2. **Ctrl/Alt chords stay `Key`**, except printable Ctrl+Alt that
|
||||||
|
/// the existing `AltGr` rule already recognizes — the caller strips
|
||||||
|
/// those modifiers before this is reached, so they arrive here as
|
||||||
|
/// plain text.
|
||||||
|
/// 3. **Meta/Super-only text stays reserved to the OS** (Q#S1-7 moves
|
||||||
|
/// the whole question to Stage 2).
|
||||||
|
/// 4. **Plain printable SINGLE-scalar stays `Key`.** This is the
|
||||||
|
/// conservative half of the ruling: it preserves mode keymaps and
|
||||||
|
/// the one-codepoint typed provenance that already exists.
|
||||||
|
/// 5. **Printable MULTI-scalar becomes one `TextInput`** — the case
|
||||||
|
/// that is broken today, truncated to its first scalar.
|
||||||
|
///
|
||||||
|
/// `Key::Dead` is 1d's, and 1a buffers nothing (rule 7); `Shift` is
|
||||||
|
/// already baked into the resolved text and is not carried (rule 8).
|
||||||
|
fn text_input_payload<'a>(
|
||||||
|
logical: &Key,
|
||||||
|
text: Option<&'a str>,
|
||||||
|
mods: Modifiers,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
// Rule 1 — a named key is never text, regardless of what winit
|
||||||
|
// reports as its text.
|
||||||
|
if matches!(logical, Key::Named(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Rules 2 and 3 — any command modifier still held at this point is
|
||||||
|
// a chord. The AltGr case has already had its modifiers stripped by
|
||||||
|
// the caller, so reaching here with Ctrl/Alt means a genuine chord.
|
||||||
|
if !is_plain_text_modifiers(mods) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = text?;
|
||||||
|
// Rule 1, second half — control text is not text.
|
||||||
|
if text.is_empty() || text.chars().any(char::is_control) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Rules 4 and 5 — the single/multi split.
|
||||||
|
if text.chars().count() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A1 — winit's function-key variants to the protocol's 1-based `F(n)`.
|
||||||
|
///
|
||||||
|
/// Written as an exhaustive match rather than parsed from the variant
|
||||||
|
/// name or computed as an offset from `F1`: winit's `NamedKey` is
|
||||||
|
/// `#[non_exhaustive]` and its ordering is not a contract, so arithmetic
|
||||||
|
/// over it would be a silent-corruption bug the day a variant is
|
||||||
|
/// inserted. `None` for anything that is not a function key, which is
|
||||||
|
/// what makes the caller's `?` fall through to "unmapped".
|
||||||
|
fn named_function_key(named: NamedKey) -> Option<u8> {
|
||||||
|
Some(match named {
|
||||||
|
NamedKey::F1 => 1,
|
||||||
|
NamedKey::F2 => 2,
|
||||||
|
NamedKey::F3 => 3,
|
||||||
|
NamedKey::F4 => 4,
|
||||||
|
NamedKey::F5 => 5,
|
||||||
|
NamedKey::F6 => 6,
|
||||||
|
NamedKey::F7 => 7,
|
||||||
|
NamedKey::F8 => 8,
|
||||||
|
NamedKey::F9 => 9,
|
||||||
|
NamedKey::F10 => 10,
|
||||||
|
NamedKey::F11 => 11,
|
||||||
|
NamedKey::F12 => 12,
|
||||||
|
NamedKey::F13 => 13,
|
||||||
|
NamedKey::F14 => 14,
|
||||||
|
NamedKey::F15 => 15,
|
||||||
|
NamedKey::F16 => 16,
|
||||||
|
NamedKey::F17 => 17,
|
||||||
|
NamedKey::F18 => 18,
|
||||||
|
NamedKey::F19 => 19,
|
||||||
|
NamedKey::F20 => 20,
|
||||||
|
NamedKey::F21 => 21,
|
||||||
|
NamedKey::F22 => 22,
|
||||||
|
NamedKey::F23 => 23,
|
||||||
|
NamedKey::F24 => 24,
|
||||||
|
NamedKey::F25 => 25,
|
||||||
|
NamedKey::F26 => 26,
|
||||||
|
NamedKey::F27 => 27,
|
||||||
|
NamedKey::F28 => 28,
|
||||||
|
NamedKey::F29 => 29,
|
||||||
|
NamedKey::F30 => 30,
|
||||||
|
NamedKey::F31 => 31,
|
||||||
|
NamedKey::F32 => 32,
|
||||||
|
NamedKey::F33 => 33,
|
||||||
|
NamedKey::F34 => 34,
|
||||||
|
NamedKey::F35 => 35,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left`
|
/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left`
|
||||||
/// is word-motion, `S-Down` extends a selection; the daemon's keymap
|
/// is word-motion, `S-Down` extends a selection; the daemon's keymap
|
||||||
/// decides).
|
/// decides).
|
||||||
|
|
@ -12117,6 +12436,20 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
|
||||||
if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) {
|
if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// A1–A3 — function keys, `BackTab` and `Menu` forward with ANY
|
||||||
|
// modifier set, for the same reason motion keys do: they are
|
||||||
|
// command keys that never insert text, so the chord-withholding
|
||||||
|
// rule below has nothing to protect them from. Translating them
|
||||||
|
// without forwarding them would have been the more expensive
|
||||||
|
// mistake — the key would map correctly and still do nothing,
|
||||||
|
// which reads as a daemon-side keymap gap rather than a frontend
|
||||||
|
// one.
|
||||||
|
if matches!(
|
||||||
|
key,
|
||||||
|
ProtocolKey::F(_) | ProtocolKey::BackTab | ProtocolKey::Menu
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if !is_plain_text_modifiers(mods) {
|
if !is_plain_text_modifiers(mods) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -13418,6 +13751,105 @@ mod tests {
|
||||||
assert_eq!(source_line_range(text, 99), (7, 10));
|
assert_eq!(source_line_range(text, 99), (7, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A1 — every function key winit names maps to the protocol's
|
||||||
|
/// 1-based `F(n)`, and forwards.
|
||||||
|
///
|
||||||
|
/// Written as an exhaustive loop over all 35 rather than spot
|
||||||
|
/// checks: the old code stopped at `_ => return None`, so F13+
|
||||||
|
/// silently did nothing, and a test covering only F1–F12 would have
|
||||||
|
/// passed against exactly that.
|
||||||
|
#[test]
|
||||||
|
fn a1_function_keys_f1_to_f35_map_and_forward() {
|
||||||
|
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};
|
||||||
|
|
||||||
|
let named = [
|
||||||
|
NamedKey::F1,
|
||||||
|
NamedKey::F2,
|
||||||
|
NamedKey::F3,
|
||||||
|
NamedKey::F4,
|
||||||
|
NamedKey::F5,
|
||||||
|
NamedKey::F6,
|
||||||
|
NamedKey::F7,
|
||||||
|
NamedKey::F8,
|
||||||
|
NamedKey::F9,
|
||||||
|
NamedKey::F10,
|
||||||
|
NamedKey::F11,
|
||||||
|
NamedKey::F12,
|
||||||
|
NamedKey::F13,
|
||||||
|
NamedKey::F14,
|
||||||
|
NamedKey::F15,
|
||||||
|
NamedKey::F16,
|
||||||
|
NamedKey::F17,
|
||||||
|
NamedKey::F18,
|
||||||
|
NamedKey::F19,
|
||||||
|
NamedKey::F20,
|
||||||
|
NamedKey::F21,
|
||||||
|
NamedKey::F22,
|
||||||
|
NamedKey::F23,
|
||||||
|
NamedKey::F24,
|
||||||
|
NamedKey::F25,
|
||||||
|
NamedKey::F26,
|
||||||
|
NamedKey::F27,
|
||||||
|
NamedKey::F28,
|
||||||
|
NamedKey::F29,
|
||||||
|
NamedKey::F30,
|
||||||
|
NamedKey::F31,
|
||||||
|
NamedKey::F32,
|
||||||
|
NamedKey::F33,
|
||||||
|
NamedKey::F34,
|
||||||
|
NamedKey::F35,
|
||||||
|
];
|
||||||
|
for (index, key) in named.into_iter().enumerate() {
|
||||||
|
let n = u8::try_from(index + 1).expect("1..=35 fits");
|
||||||
|
let (k, m) = translate_key(&WKey::Named(key), ModifiersState::empty())
|
||||||
|
.unwrap_or_else(|| panic!("F{n} must map"));
|
||||||
|
assert_eq!(k, ProtocolKey::F(n), "F{n} maps to F({n})");
|
||||||
|
assert!(
|
||||||
|
should_forward_key(k, m),
|
||||||
|
"F{n} must FORWARD; translating without forwarding leaves \
|
||||||
|
the key mapped and inert"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A2 — Shift+Tab is `BackTab`, and the `Shift` stays set.
|
||||||
|
///
|
||||||
|
/// Both halves matter: the daemon binds `Tab` and `BackTab`
|
||||||
|
/// differently, and a `BackTab` that lost its modifier would be
|
||||||
|
/// indistinguishable from one the user did not shift.
|
||||||
|
#[test]
|
||||||
|
fn a2_shift_tab_is_backtab_with_shift_retained() {
|
||||||
|
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};
|
||||||
|
|
||||||
|
let (plain, plain_mods) =
|
||||||
|
translate_key(&WKey::Named(NamedKey::Tab), ModifiersState::empty()).expect("Tab maps");
|
||||||
|
assert_eq!(plain, ProtocolKey::Tab);
|
||||||
|
assert!(plain_mods.is_empty());
|
||||||
|
|
||||||
|
let (shifted, shifted_mods) =
|
||||||
|
translate_key(&WKey::Named(NamedKey::Tab), ModifiersState::SHIFT)
|
||||||
|
.expect("Shift+Tab maps");
|
||||||
|
assert_eq!(shifted, ProtocolKey::BackTab);
|
||||||
|
assert!(
|
||||||
|
shifted_mods.contains(Modifiers::SHIFT),
|
||||||
|
"Shift is retained on BackTab"
|
||||||
|
);
|
||||||
|
assert!(should_forward_key(shifted, shifted_mods));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3 — the menu key reaches the daemon. `ProtocolKey::Menu` and the
|
||||||
|
/// TUI's mapping both already existed; only the GPU translation was
|
||||||
|
/// missing, so the key did nothing in the GUI.
|
||||||
|
#[test]
|
||||||
|
fn a3_context_menu_maps_to_menu_and_forwards() {
|
||||||
|
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};
|
||||||
|
|
||||||
|
let (k, m) = translate_key(&WKey::Named(NamedKey::ContextMenu), ModifiersState::empty())
|
||||||
|
.expect("ContextMenu maps");
|
||||||
|
assert_eq!(k, ProtocolKey::Menu);
|
||||||
|
assert!(should_forward_key(k, m), "and forwards, or it is inert");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn translate_key_maps_motion_named_keys_and_chars() {
|
fn translate_key_maps_motion_named_keys_and_chars() {
|
||||||
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey, SmolStr};
|
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey, SmolStr};
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,9 @@ pub use message::{
|
||||||
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
||||||
StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name,
|
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace,
|
||||||
is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
|
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
||||||
negotiated_session_version, requested_protocol_version,
|
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
|
||||||
};
|
};
|
||||||
pub use panel::{
|
pub use panel::{
|
||||||
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
|
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
|
||||||
|
|
|
||||||
|
|
@ -513,8 +513,62 @@ pub enum FrontendEvent {
|
||||||
/// Modifiers held during the gesture.
|
/// Modifiers held during the gesture.
|
||||||
mods: Modifiers,
|
mods: Modifiers,
|
||||||
},
|
},
|
||||||
|
/// Committed text from a keypress or an IME composition — GUI arc
|
||||||
|
/// Stage 1a, **protocol v24** ([`TEXT_INPUT_MIN_VERSION`]).
|
||||||
|
///
|
||||||
|
/// **APPENDED, never widened.** postcard encodes an enum variant by
|
||||||
|
/// its positional index, so adding a field to any variant above
|
||||||
|
/// would silently re-interpret every older peer's bytes. A new
|
||||||
|
/// variant at the end is the only backward-compatible shape, which
|
||||||
|
/// is also why the frozen-byte pin in the tests sits on
|
||||||
|
/// [`FrontendEvent::PanelPointer`] — the *previous* final variant —
|
||||||
|
/// rather than on this one: an appended variant's own round-trip
|
||||||
|
/// cannot detect a discriminant shift beneath it.
|
||||||
|
///
|
||||||
|
/// **This is not [`FrontendEvent::Paste`], and the difference is
|
||||||
|
/// behavioural rather than cosmetic.** A paste is bulk data from
|
||||||
|
/// elsewhere; this is what the user *typed*, so a terminal receives
|
||||||
|
/// it as **raw UTF-8 and never inside bracketed-paste markers** — a
|
||||||
|
/// shell that sees `ESC[200~` around typed input treats it as
|
||||||
|
/// pasted, which changes how it handles newlines and completion.
|
||||||
|
///
|
||||||
|
/// **One `TextInput` is ONE edit**: one undo unit, one
|
||||||
|
/// `buffer.after-edit`, one eligible CRDT op. The multi-scalar case
|
||||||
|
/// is the whole reason the variant exists — a two-scalar grapheme
|
||||||
|
/// arriving as two keypresses is two undo units and, worse, can be
|
||||||
|
/// split by an intervening remote edit.
|
||||||
|
///
|
||||||
|
/// `text` is capped at [`TEXT_INPUT_MAX_BYTES`]; an oversize payload
|
||||||
|
/// is **rejected, never truncated**, because truncating a UTF-8
|
||||||
|
/// sequence at a byte boundary silently corrupts the last character
|
||||||
|
/// and a silently-shortened insert is worse than a refused one.
|
||||||
|
TextInput {
|
||||||
|
/// Which frontend produced the text. **Untrusted**, like every
|
||||||
|
/// other `frontend_id` on this enum — the daemon uses the
|
||||||
|
/// authenticated source, not this field.
|
||||||
|
frontend_id: FrontendId,
|
||||||
|
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First protocol version carrying [`FrontendEvent::TextInput`].
|
||||||
|
///
|
||||||
|
/// A frontend older than this **retains its existing `Key` behaviour and
|
||||||
|
/// its existing limitations** — it truncates multi-scalar input to the
|
||||||
|
/// first scalar today and ignores IME, and it continues to. The promise
|
||||||
|
/// is **no regression, not retroactive correctness**: nothing a `< 24`
|
||||||
|
/// peer already had degrades, and the daemon simply never receives a
|
||||||
|
/// variant such a peer cannot encode.
|
||||||
|
pub const TEXT_INPUT_MIN_VERSION: u32 = 24;
|
||||||
|
|
||||||
|
/// Cap on [`FrontendEvent::TextInput::text`], in bytes of UTF-8.
|
||||||
|
///
|
||||||
|
/// 64 KiB is far above any keystroke or IME commit and far below a
|
||||||
|
/// pathological paste, which has its own event. **Oversize is rejected
|
||||||
|
/// rather than truncated** — see the variant's own documentation.
|
||||||
|
pub const TEXT_INPUT_MAX_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
|
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
|
||||||
/// detection is frontend-side (`DoubleDown` instead of a second
|
/// detection is frontend-side (`DoubleDown` instead of a second
|
||||||
/// `Down`): only the frontend knows pixel proximity and its own
|
/// `Down`): only the frontend knows pixel proximity and its own
|
||||||
|
|
@ -562,7 +616,8 @@ impl FrontendEvent {
|
||||||
| Self::TerminalPointer { frontend_id, .. }
|
| Self::TerminalPointer { frontend_id, .. }
|
||||||
| Self::FrontendCellGeometry { frontend_id, .. }
|
| Self::FrontendCellGeometry { frontend_id, .. }
|
||||||
| Self::PanelResizeRows { frontend_id, .. }
|
| Self::PanelResizeRows { frontend_id, .. }
|
||||||
| Self::PanelPointer { frontend_id, .. } => *frontend_id,
|
| Self::PanelPointer { frontend_id, .. }
|
||||||
|
| Self::TextInput { frontend_id, .. } => *frontend_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1840,7 +1895,32 @@ pub enum ResourceBody {
|
||||||
/// encoding makes an in-place widening a wire break rather than an
|
/// encoding makes an in-place widening a wire break rather than an
|
||||||
/// evolution, and gating the widened form would have left those peers
|
/// evolution, and gating the widened form would have left those peers
|
||||||
/// with no minibuffer message at all.
|
/// with no minibuffer message at all.
|
||||||
pub const PROTOCOL_VERSION: u32 = 23;
|
///
|
||||||
|
/// GUI arc Stage 1a: bumped 23 → 24 for
|
||||||
|
/// [`FrontendEvent::TextInput`] — committed text from a keypress or an
|
||||||
|
/// IME composition, carried as one event so that a multi-scalar
|
||||||
|
/// grapheme is one edit, one undo unit and one eligible CRDT op instead
|
||||||
|
/// of being truncated to its first scalar. Appended after
|
||||||
|
/// `PanelPointer`, the final v23 `FrontendEvent` variant, so no
|
||||||
|
/// existing discriminant moves.
|
||||||
|
///
|
||||||
|
/// **The gate is producer-side AND receiver-side**, and for an inbound
|
||||||
|
/// variant the receiving half is the load-bearing one. An
|
||||||
|
/// instance→frontend variant is gated by the daemon simply not sending
|
||||||
|
/// it, which is entirely within the daemon's control; an inbound variant
|
||||||
|
/// cannot be, because the withholding would be the *peer's* job and a
|
||||||
|
/// client built from this same crate can encode the discriminant
|
||||||
|
/// whatever it negotiated. So the daemon refuses `TextInput` from a
|
||||||
|
/// session below [`TEXT_INPUT_MIN_VERSION`] rather than trusting the
|
||||||
|
/// producer to withhold — otherwise a v6–v23 session could drive an edit
|
||||||
|
/// through a variant its own session never declared.
|
||||||
|
///
|
||||||
|
/// This is not a new shape: the v19 terminal and v21 panel families
|
||||||
|
/// already gate their own inbound events on the authenticated session's
|
||||||
|
/// negotiated version. What is unusual here is only that the extension
|
||||||
|
/// is **inbound-only** — there is no outbound counterpart to withhold,
|
||||||
|
/// so the receiver check is the whole of the daemon's half.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 24;
|
||||||
|
|
||||||
/// Protocol version placed in the daemon's server-first [`Hello`].
|
/// Protocol version placed in the daemon's server-first [`Hello`].
|
||||||
///
|
///
|
||||||
|
|
@ -2021,8 +2101,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
|
||||||
/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a
|
/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a
|
||||||
/// `>= 23` peer receives only the rows form, and no peer ever receives
|
/// `>= 23` peer receives only the rows form, and no peer ever receives
|
||||||
/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move.
|
/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move.
|
||||||
|
///
|
||||||
|
/// GUI arc Stage 1a: extended to `[6, ..., 24]` for
|
||||||
|
/// [`FrontendEvent::TextInput`]. Additive, and gated producer-side AND
|
||||||
|
/// receiver-side — see [`PROTOCOL_VERSION`] for why an inbound variant
|
||||||
|
/// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23
|
||||||
|
/// frontend negotiates v23, never sends the variant, and keeps today's
|
||||||
|
/// first-scalar behaviour.
|
||||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
||||||
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
|
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ use crate::protocol::{
|
||||||
InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
|
InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
|
||||||
InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES,
|
InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES,
|
||||||
PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest,
|
PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest,
|
||||||
|
TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION,
|
||||||
};
|
};
|
||||||
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
|
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
|
||||||
use crate::transport::{read_message, write_message};
|
use crate::transport::{read_message, write_message};
|
||||||
|
|
@ -2518,6 +2519,55 @@ fn handle_dispatcher_event(
|
||||||
handle_inbound_paste(editor, source, claimed_fid, &data);
|
handle_inbound_paste(editor, source, claimed_fid, &data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FrontendEvent::TextInput { text, .. } => {
|
||||||
|
// GUI arc 1a / A5 — committed text from a keypress
|
||||||
|
// or an IME composition. Handled here, beside
|
||||||
|
// `Paste`, for the same two reasons: the
|
||||||
|
// authenticated `source` is in scope (the event's
|
||||||
|
// own `frontend_id` is client-supplied and not
|
||||||
|
// trusted), and the semantic input dispatcher would
|
||||||
|
// otherwise drop it, which is exactly how GPU
|
||||||
|
// Ctrl-V was a no-op before Q#KR10a.
|
||||||
|
//
|
||||||
|
// A9 — the cap is enforced at the BOUNDARY, before
|
||||||
|
// anything is inserted. Rejected, never truncated:
|
||||||
|
// cutting UTF-8 at a byte offset corrupts the last
|
||||||
|
// character, and a silently-shortened insert is
|
||||||
|
// worse than a refused one. An empty payload is
|
||||||
|
// dropped too — it would be an edit that edits
|
||||||
|
// nothing, and would still cost an undo unit.
|
||||||
|
// **The producer gate is only half the contract.**
|
||||||
|
// A frontend that negotiated v6–v23 can still encode
|
||||||
|
// this variant — it is compiled from the same crate,
|
||||||
|
// and postcard will happily write the discriminant —
|
||||||
|
// so a peer that never declared v24 could otherwise
|
||||||
|
// mutate the buffer through a variant its own
|
||||||
|
// session does not include. Gate on the
|
||||||
|
// AUTHENTICATED session's negotiated version, not on
|
||||||
|
// the payload and not on what the daemon supports.
|
||||||
|
let peer_declared_text_input = session_registry
|
||||||
|
.session_state(source)
|
||||||
|
.is_some_and(|s| s.negotiated_protocol_version >= TEXT_INPUT_MIN_VERSION);
|
||||||
|
if !peer_declared_text_input {
|
||||||
|
eprintln!(
|
||||||
|
"pmacs: dropping TextInput from {source:?}, which negotiated \
|
||||||
|
below v{TEXT_INPUT_MIN_VERSION}"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.len() > TEXT_INPUT_MAX_BYTES {
|
||||||
|
eprintln!(
|
||||||
|
"pmacs: rejecting oversize TextInput from {source:?} \
|
||||||
|
({} bytes > {TEXT_INPUT_MAX_BYTES})",
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.dispatch_text_input(source, &text);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let Some(&term_size) = term_sizes.get(&source) else {
|
let Some(&term_size) = term_sizes.get(&source) else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -3588,12 +3638,15 @@ fn apply_event(
|
||||||
render_state.resize(size);
|
render_state.resize(size);
|
||||||
*term_size = size;
|
*term_size = size;
|
||||||
}
|
}
|
||||||
// Q#KR10a — Paste is handled in the dispatcher's own
|
// Q#KR10a (Paste) and GUI arc 1a (TextInput) — both are handled
|
||||||
// `FrontendEvent::Paste` arm (unified for grid and semantic
|
// in the dispatcher's own arms, unified for grid and semantic
|
||||||
// sessions, keyed by the authenticated source), and never
|
// sessions and keyed by the AUTHENTICATED source, and neither
|
||||||
// reaches here. Listed explicitly so a future reshuffle can't
|
// reaches here. Listed explicitly rather than left to a
|
||||||
// silently re-route it through this payload-trusting path.
|
// wildcard so a future reshuffle cannot silently re-route
|
||||||
|
// either through this payload-trusting path: both carry a
|
||||||
|
// client-supplied `frontend_id` this function would believe.
|
||||||
FrontendEvent::Paste { .. }
|
FrontendEvent::Paste { .. }
|
||||||
|
| FrontendEvent::TextInput { .. }
|
||||||
| FrontendEvent::FocusGained(_)
|
| FrontendEvent::FocusGained(_)
|
||||||
| FrontendEvent::FocusLost(_)
|
| FrontendEvent::FocusLost(_)
|
||||||
// T M11.1: the semantic-frontend viewport declaration. Its
|
// T M11.1: the semantic-frontend viewport declaration. Its
|
||||||
|
|
|
||||||
155
src/editor.rs
155
src/editor.rs
|
|
@ -1881,6 +1881,161 @@ impl EditorState {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GUI arc Stage 1a / A5 — apply committed text from a keypress or
|
||||||
|
/// an IME composition.
|
||||||
|
///
|
||||||
|
/// **This is typed text, not a paste**, and the two differ in three
|
||||||
|
/// observable ways:
|
||||||
|
///
|
||||||
|
/// * a terminal gets **raw UTF-8, never bracketed paste** (A8) — a
|
||||||
|
/// shell that sees `ESC[200~` treats the input as pasted and
|
||||||
|
/// changes how it handles newlines and completion;
|
||||||
|
/// * the clipboard slot is **not** touched, because nothing was
|
||||||
|
/// copied;
|
||||||
|
/// * the document path performs **one atomic edit** (A6) — one undo
|
||||||
|
/// unit, one `buffer.after-edit`, one eligible CRDT op — which is
|
||||||
|
/// the entire reason the wire variant exists. Delivering a
|
||||||
|
/// two-scalar grapheme as two keypresses makes two undo units and
|
||||||
|
/// lets a remote edit interleave between them.
|
||||||
|
///
|
||||||
|
/// The modal precedence is `dispatch_key`'s, deliberately: menu,
|
||||||
|
/// search, query-replace and minibuffer are full keymap shadows, so
|
||||||
|
/// text that arrives while one is up belongs to it and not to the
|
||||||
|
/// buffer underneath. Those surfaces have no notion of a
|
||||||
|
/// multi-scalar commit, so the text is fed to them **one scalar at
|
||||||
|
/// a time, in order** (A7) — order is the contract, since a prompt
|
||||||
|
/// accumulates a query.
|
||||||
|
///
|
||||||
|
/// Returns nothing, unlike [`Self::dispatch_paste`], and the
|
||||||
|
/// difference is real rather than stylistic: §5's precedence is
|
||||||
|
/// **total** — shadow, terminal, or document, every state routes
|
||||||
|
/// somewhere — so there is no "unhandled" case for a caller to fall
|
||||||
|
/// back on.
|
||||||
|
pub fn dispatch_text_input(&mut self, frontend_id: FrontendId, text: &str) {
|
||||||
|
self.core.borrow_mut().active_frontend = frontend_id;
|
||||||
|
|
||||||
|
// Shadows first, one scalar at a time and in order (A7).
|
||||||
|
if self.feed_shadow_scalars(frontend_id, text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A8 — a terminal takes the bytes exactly as typed.
|
||||||
|
if let Some(key) = self.active_terminal_key(frontend_id) {
|
||||||
|
self.claim_terminal_controller(key);
|
||||||
|
self.send_terminal_bytes(key.buffer_id, text.as_bytes());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5's provenance split. A SINGLE-scalar commit is
|
||||||
|
// indistinguishable from a keypress, so it must be
|
||||||
|
// indistinguishable downstream too: it rotates to
|
||||||
|
// `buffer.self-insert` and produces the same one-codepoint
|
||||||
|
// typed-edit record a keystroke does. Without that, auto-pairing
|
||||||
|
// (Q#AP9) and every other typed-edit consumer silently stop
|
||||||
|
// recognizing GUI input, and `this_command` goes stale — a
|
||||||
|
// regression that shows up as "auto-pair stopped working in the
|
||||||
|
// GUI", far from its cause.
|
||||||
|
//
|
||||||
|
// A MULTI-scalar commit is not a keystroke: it breaks the
|
||||||
|
// command chain and creates no typed provenance, exactly as a
|
||||||
|
// paste does.
|
||||||
|
let single = {
|
||||||
|
let mut chars = text.chars();
|
||||||
|
match (chars.next(), chars.next()) {
|
||||||
|
(Some(ch), None) => Some(ch),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pre_revision = self.active_buffer_revision();
|
||||||
|
{
|
||||||
|
let mut core = self.core.borrow_mut();
|
||||||
|
if let Some(ch) = single {
|
||||||
|
core.rotate_command(frontend_id, "buffer.self-insert");
|
||||||
|
core.typed_edit_arm(frontend_id, ch);
|
||||||
|
// **Must go through `insert_char_over_region`, not
|
||||||
|
// the generic byte insert.** Arming provenance is
|
||||||
|
// only half of it: `typed_edit_complete` is called
|
||||||
|
// from `insert_char` / `insert_char_over_region` and
|
||||||
|
// nowhere else, so a generic insert leaves the arm
|
||||||
|
// holding `None` and `buffer.after-edit` sees no
|
||||||
|
// record — `this_command` rotates correctly and
|
||||||
|
// auto-pairing still fails, which is a failure mode
|
||||||
|
// that looks like success from the command side.
|
||||||
|
// It handles the no-region case itself, by
|
||||||
|
// delegating to `insert_char`.
|
||||||
|
core.insert_char_over_region(ch);
|
||||||
|
} else {
|
||||||
|
core.break_command_chain(frontend_id);
|
||||||
|
// A6 — multi-scalar is ONE generic edit, and
|
||||||
|
// deliberately creates no typed provenance: it is not a
|
||||||
|
// keystroke.
|
||||||
|
if let Err(e) = core.insert_text_input(text) {
|
||||||
|
eprintln!("pmacs: text input failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dispatch tail `dispatch_key` runs, for the same reason: a
|
||||||
|
// typed-edit record is only meaningful to a hook that can see
|
||||||
|
// it, so it is armed across the fan-out and cleared after.
|
||||||
|
let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id);
|
||||||
|
if pre_revision != self.active_buffer_revision() {
|
||||||
|
if let Some(record) = typed_edit {
|
||||||
|
self.core
|
||||||
|
.borrow_mut()
|
||||||
|
.typed_edit_set_armed(frontend_id, record);
|
||||||
|
}
|
||||||
|
self.lua_host
|
||||||
|
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||||
|
self.core.borrow_mut().typed_edit_clear_armed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed `text` to whichever modal shadow owns input, one scalar at a
|
||||||
|
/// time and in order. Returns `true` when a shadow consumed it.
|
||||||
|
///
|
||||||
|
/// Each scalar becomes a plain `Char` chord, which is what these
|
||||||
|
/// handlers already take from `dispatch_key`; routing through them
|
||||||
|
/// rather than reaching into their state is what keeps a prompt's
|
||||||
|
/// own editing rules — history, completion, acceptance — in one
|
||||||
|
/// place.
|
||||||
|
fn feed_shadow_scalars(&mut self, frontend_id: FrontendId, text: &str) -> bool {
|
||||||
|
enum Shadow {
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
QueryReplace,
|
||||||
|
Minibuffer,
|
||||||
|
}
|
||||||
|
let shadow = {
|
||||||
|
let core = self.core.borrow();
|
||||||
|
if core.menu_is_open() {
|
||||||
|
Some(Shadow::Menu)
|
||||||
|
} else if core.search_active() {
|
||||||
|
Some(Shadow::Search)
|
||||||
|
} else if core.query_replace_active() {
|
||||||
|
Some(Shadow::QueryReplace)
|
||||||
|
} else if core.minibuffer.is_active() {
|
||||||
|
Some(Shadow::Minibuffer)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(shadow) = shadow else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for ch in text.chars() {
|
||||||
|
let chord = Chord::plain(KeyCode::Char(ch));
|
||||||
|
match shadow {
|
||||||
|
Shadow::Menu => self.dispatch_menu_key(frontend_id, chord),
|
||||||
|
Shadow::Search => self.dispatch_search_key(chord),
|
||||||
|
Shadow::QueryReplace => self.dispatch_query_replace_key(chord),
|
||||||
|
Shadow::Minibuffer => self.dispatch_minibuffer_key(frontend_id, chord),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply authenticated frontend focus to terminal control/reporting.
|
/// Apply authenticated frontend focus to terminal control/reporting.
|
||||||
pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) {
|
pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) {
|
||||||
self.core.borrow_mut().active_frontend = frontend_id;
|
self.core.borrow_mut().active_frontend = frontend_id;
|
||||||
|
|
|
||||||
|
|
@ -478,8 +478,9 @@ pub struct CommandBoundary {
|
||||||
/// the exact facts for the one consumer contract that needs them (the
|
/// the exact facts for the one consumer contract that needs them (the
|
||||||
/// pairing hook): the decoded codepoint, the requested and effective
|
/// pairing hook): the decoded codepoint, the requested and effective
|
||||||
/// ranges, and the post-edit cursor, plus a `clean` verdict (effective
|
/// ranges, and the post-edit cursor, plus a `clean` verdict (effective
|
||||||
/// triple equals the request). It is ephemeral — armed by the two
|
/// triple equals the request). It is ephemeral — armed by the three
|
||||||
/// self-insert producers (dispatch fallback, optimistic CRDT arm) for
|
/// self-insert producers (dispatch fallback, optimistic CRDT arm, and
|
||||||
|
/// the single-scalar `TextInput` path of GUI arc Stage 1a) for
|
||||||
/// exactly one `buffer.after-edit` fan-out, consumable once via
|
/// exactly one `buffer.after-edit` fan-out, consumable once via
|
||||||
/// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out
|
/// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out
|
||||||
/// returns. Paste, programmatic mutation, manual hook runs, and a
|
/// returns. Paste, programmatic mutation, manual hook runs, and a
|
||||||
|
|
@ -687,8 +688,10 @@ pub struct EditorCore {
|
||||||
/// query-replace twin of `search`; drives the fifth dispatcher
|
/// query-replace twin of `search`; drives the fifth dispatcher
|
||||||
/// shadow.
|
/// shadow.
|
||||||
query_replace: Option<QueryReplaceSession>,
|
query_replace: Option<QueryReplaceSession>,
|
||||||
/// In-flight typed-edit arm (auto-pairing Q#AP9): set by the
|
/// In-flight typed-edit arm (auto-pairing Q#AP9): set by a
|
||||||
/// dispatch fallback just before it invokes `buffer.self-insert`,
|
/// self-insert producer just before the edit — the dispatch
|
||||||
|
/// fallback invoking `buffer.self-insert`, or 1a's single-scalar
|
||||||
|
/// `TextInput` path —
|
||||||
/// completed by the insert primitives, taken back by the
|
/// completed by the insert primitives, taken back by the
|
||||||
/// dispatcher via [`Self::typed_edit_finish`] in the same
|
/// dispatcher via [`Self::typed_edit_finish`] in the same
|
||||||
/// dispatch. Never survives a dispatch cycle.
|
/// dispatch. Never survives a dispatch cycle.
|
||||||
|
|
@ -4808,9 +4811,19 @@ impl EditorCore {
|
||||||
/// Declare that `fid`'s dispatch is about to invoke
|
/// Declare that `fid`'s dispatch is about to invoke
|
||||||
/// `buffer.self-insert` for `codepoint`: the next insert primitive
|
/// `buffer.self-insert` for `codepoint`: the next insert primitive
|
||||||
/// whose character matches completes the [`TypedEditRecord`].
|
/// whose character matches completes the [`TypedEditRecord`].
|
||||||
/// Called by the dispatch fallback only — programmatic
|
/// Called by the self-insert producers: the dispatch fallback, and
|
||||||
|
/// the **single-scalar** `TextInput` path (GUI arc 1a), which must
|
||||||
|
/// be indistinguishable from a keypress downstream. Programmatic
|
||||||
/// `pmacs.command.invoke("buffer.self-insert")` deliberately never
|
/// `pmacs.command.invoke("buffer.self-insert")` deliberately never
|
||||||
/// arms, so a hook run after it observes no record.
|
/// arms, so a hook run after it observes no record; nor does a
|
||||||
|
/// MULTI-scalar `TextInput`, which is not a keystroke.
|
||||||
|
///
|
||||||
|
/// **Arming is only half.** Completion happens in the insert
|
||||||
|
/// primitives ([`Self::insert_char`] /
|
||||||
|
/// [`Self::insert_char_over_region`]) and nowhere else, so a caller
|
||||||
|
/// that arms and then performs a generic byte insert leaves the arm
|
||||||
|
/// holding `None` — `this_command` looks right and auto-pairing
|
||||||
|
/// silently stops working.
|
||||||
pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) {
|
pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) {
|
||||||
self.typed_edit_pending = Some(TypedEditPending {
|
self.typed_edit_pending = Some(TypedEditPending {
|
||||||
fid,
|
fid,
|
||||||
|
|
@ -5009,6 +5022,25 @@ impl EditorCore {
|
||||||
self.insert_bytes_over_region(data)
|
self.insert_bytes_over_region(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GUI arc Stage 1a / A6 — insert **multi-scalar** committed text as
|
||||||
|
/// one edit.
|
||||||
|
///
|
||||||
|
/// **Single-scalar text does NOT come here**; it goes through
|
||||||
|
/// [`Self::insert_char_over_region`], which is the only path (with
|
||||||
|
/// [`Self::insert_char`]) that completes a [`TypedEditRecord`].
|
||||||
|
/// Routing a single scalar here would arm provenance and never
|
||||||
|
/// complete it — see `EditorState::dispatch_text_input`.
|
||||||
|
///
|
||||||
|
/// Shares [`Self::insert_bytes_over_region`] with paste, which is
|
||||||
|
/// what makes it a single `EditOp` and therefore a single undo
|
||||||
|
/// unit, a single `buffer.after-edit`, and a single eligible CRDT
|
||||||
|
/// op. **It deliberately does NOT touch `clipboard_slot`**: typed
|
||||||
|
/// text was never copied, and recording it would let the next yank
|
||||||
|
/// resurrect something the user merely typed.
|
||||||
|
pub fn insert_text_input(&mut self, text: &str) -> Result<(), String> {
|
||||||
|
self.insert_bytes_over_region(text.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared insert/replace for paste: `Replace` over the active
|
/// Shared insert/replace for paste: `Replace` over the active
|
||||||
/// region, else `Insert` at the cursor. The cursor lands just past
|
/// region, else `Insert` at the cursor. The cursor lands just past
|
||||||
/// the inserted bytes and any selection is cleared. No-op insert for
|
/// the inserted bytes and any selection is cleared. No-op insert for
|
||||||
|
|
|
||||||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
||||||
// --- M5.5a handshake & postcard round-trips ---
|
// --- M5.5a handshake & postcard round-trips ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() {
|
fn protocol_version_is_twenty_four_for_text_input() {
|
||||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||||
|
|
@ -1740,7 +1740,11 @@ mod tests {
|
||||||
// and gating the wider form would have left them with no
|
// and gating the wider form would have left them with no
|
||||||
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||||
// pinned by literal bytes below.
|
// pinned by literal bytes below.
|
||||||
assert_eq!(PROTOCOL_VERSION, 23);
|
//
|
||||||
|
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an
|
||||||
|
// APPENDED variant, which is why the freeze above survives it
|
||||||
|
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1817,18 +1821,19 @@ mod tests {
|
||||||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||||
// panel band), v22 (`LineWrapFacts`), and v23
|
// panel band), v22 (`LineWrapFacts`), v23
|
||||||
// (`MinibufferPromptRows`) all interoperate.
|
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage
|
||||||
for accepted in 6..=23 {
|
// 1a) all interoperate.
|
||||||
|
for accepted in 6..=24 {
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(accepted),
|
is_supported_protocol_version(accepted),
|
||||||
"v{accepted} must be accepted"
|
"v{accepted} must be accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] {
|
for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] {
|
||||||
assert!(
|
assert!(
|
||||||
!is_supported_protocol_version(rejected),
|
!is_supported_protocol_version(rejected),
|
||||||
"v{rejected} must be rejected by a v23 binary"
|
"v{rejected} must be rejected by a v24 binary"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1966,6 +1971,43 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_pointer_encoding_is_unchanged_by_the_v24_build() {
|
||||||
|
// GUI arc Stage 1a — the placement pin for `TextInput`, and it
|
||||||
|
// sits on `PanelPointer` rather than on `TextInput` itself for
|
||||||
|
// a structural reason: `TextInput` is APPENDED, so its own
|
||||||
|
// round-trip is identical whether or not a variant was inserted
|
||||||
|
// beneath it. Only the PREVIOUS final variant's bytes move, so
|
||||||
|
// only they can witness the shift.
|
||||||
|
//
|
||||||
|
// `PanelPointer` was the last `FrontendEvent` variant at v23.
|
||||||
|
// Inserting anything before it shifts its discriminant and
|
||||||
|
// breaks **v21–v23 panel traffic** — `PanelPointer` arrived at
|
||||||
|
// v21 and is gated, so that is its whole population, not every
|
||||||
|
// peer and not every session. Stated exactly rather than
|
||||||
|
// dramatically: an earlier version of this comment claimed
|
||||||
|
// every v6–v23 daemon on every session, which is false and
|
||||||
|
// would send the next reader hunting a larger blast radius than
|
||||||
|
// exists.
|
||||||
|
let ev = FrontendEvent::PanelPointer {
|
||||||
|
frontend_id: FrontendId(2),
|
||||||
|
geometry_epoch: 1,
|
||||||
|
panel_epoch: 1,
|
||||||
|
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||||
|
coord: CellCoord { row: 0, col: 0 },
|
||||||
|
kind: pmacs_protocol::MouseKind::Move,
|
||||||
|
mods: Modifiers::NONE,
|
||||||
|
};
|
||||||
|
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||||||
|
assert_eq!(
|
||||||
|
bytes,
|
||||||
|
[15, 2, 1, 1, 4, 0, 0, 3, 0],
|
||||||
|
"PanelPointer's v23 wire bytes changed — a variant was \
|
||||||
|
inserted before it; append new FrontendEvent variants at \
|
||||||
|
the end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
||||||
let bid = pmacs_protocol::BufferId::from_raw(9);
|
let bid = pmacs_protocol::BufferId::from_raw(9);
|
||||||
|
|
@ -2738,9 +2780,20 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn m4_6_handshake_accepts_v6_peer() {
|
fn m4_6_handshake_accepts_v6_peer() {
|
||||||
|
// ANCHORED ON THE LITERAL 6, deliberately. The body used to
|
||||||
|
// assert `is_supported_protocol_version(PROTOCOL_VERSION)` —
|
||||||
|
// "the current wire accepts itself" — which is a different and
|
||||||
|
// much weaker claim than the name and the M4.6 contract make:
|
||||||
|
// **v6 is the FLOOR**, the oldest peer the handshake still
|
||||||
|
// admits, and it must keep being accepted no matter how far the
|
||||||
|
// ceiling moves. Written against the moving constant, the test
|
||||||
|
// would have gone on passing after v6 was dropped from the
|
||||||
|
// supported set, which is the only regression it exists to
|
||||||
|
// catch. Found when the v24 bump made it fail for the unrelated
|
||||||
|
// reason that `SUPPORTED_PROTOCOL_VERSIONS` had not been widened.
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(PROTOCOL_VERSION),
|
is_supported_protocol_version(6),
|
||||||
"the current wire version must accept itself"
|
"v6 is the floor and must stay accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -337,9 +337,10 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_baseline_stays_and_the_counter_offer_activates() {
|
fn the_baseline_stays_and_the_counter_offer_activates() {
|
||||||
// A deliberate tripwire: bumping the wire must be a conscious edit
|
// A deliberate tripwire: bumping the wire must be a conscious edit
|
||||||
// here, not a silent one. v23 is `MinibufferPromptRows` (Discovery
|
// here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a);
|
||||||
// Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
// v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was
|
||||||
assert_eq!(PROTOCOL_VERSION, 23);
|
// `LineWrapFacts` (long-lines Stage 3).
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 24);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
"moving this is the incompatible act the mechanism exists to avoid"
|
"moving this is the incompatible act the mechanism exists to avoid"
|
||||||
|
|
|
||||||
|
|
@ -67,25 +67,31 @@ use common::daemon::{TestDaemon, build_default_caps};
|
||||||
/// server-first, so moving it locks out every already-shipped frontend
|
/// server-first, so moving it locks out every already-shipped frontend
|
||||||
/// before it can counter-offer. An additive family never needs it.
|
/// before it can counter-offer. An additive family never needs it.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() {
|
fn the_wire_is_v24_and_the_advertised_baseline_is_unmoved() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
PROTOCOL_VERSION, 23,
|
PROTOCOL_VERSION, 24,
|
||||||
"v23 is MinibufferPromptRows (Discovery Stage 2)"
|
"v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||||
"moving this is the incompatible act the counter-offer mechanism exists to avoid"
|
"moving this is the incompatible act the counter-offer mechanism exists to avoid"
|
||||||
);
|
);
|
||||||
// The whole v12..=22 population this lane is compatible with is
|
// The whole v12..=22 population this lane is compatible with is
|
||||||
// still supported, and the set ends at the new wire — a widened set
|
// still supported, AND the current wire is in the set. The loop
|
||||||
// is a failure rather than a silent pass.
|
// must run to `PROTOCOL_VERSION`, not to a literal: stopping at 23
|
||||||
for version in 6..=23 {
|
// let a supported range that ended at 23 pass this test while
|
||||||
|
// `PROTOCOL_VERSION` was already 24 — the accepted half proved
|
||||||
|
// nothing about the version the constant names.
|
||||||
|
for version in 6..=PROTOCOL_VERSION {
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(version),
|
is_supported_protocol_version(version),
|
||||||
"v{version} must still be supported"
|
"v{version} must still be supported"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(!is_supported_protocol_version(24));
|
// The ceiling: the supported set ENDS at the current wire, which
|
||||||
|
// is what makes an accidentally-widened set a failure rather than
|
||||||
|
// a silent pass. Probes one PAST the top, so it moves with it.
|
||||||
|
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -612,7 +618,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() {
|
||||||
// rather than after the interesting half has already passed.
|
// rather than after the interesting half has already passed.
|
||||||
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
||||||
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||||
assert_eq!(PROTOCOL_VERSION, 23);
|
assert_eq!(PROTOCOL_VERSION, 24);
|
||||||
|
|
||||||
// Open the real `M-x` through the real key path, then narrow to the
|
// Open the real `M-x` through the real key path, then narrow to the
|
||||||
// probe command by typing it — the candidate window is ten rows out
|
// probe command by typing it — the candidate window is ten rows out
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
//! GUI arc Stage 1a acceptance — `TextInput` at protocol v24.
|
||||||
|
//!
|
||||||
|
//! Framing: `docs/gui-stage1-input-framing.md` §5 (Q#S1-9 precedence)
|
||||||
|
//! and §6's A1–A9.
|
||||||
|
//!
|
||||||
|
//! **These rows drive the real dispatch, not the classifier.** 1a's
|
||||||
|
//! first review found A7 and A8 unreachable from the production
|
||||||
|
//! producer while `text_input_payload` was perfectly correct: the
|
||||||
|
//! intercept branch returned before classification, and a modal prompt
|
||||||
|
//! or a focused terminal is exactly what makes intercept true. A test
|
||||||
|
//! that exercises the pure function would have stayed green through
|
||||||
|
//! that, so the rows here go through `dispatch_text_input` and, for the
|
||||||
|
//! producer-side ones, through the real classifier at the real call
|
||||||
|
//! site.
|
||||||
|
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
use pmacs::protocol::FrontendId;
|
||||||
|
|
||||||
|
fn exec(s: &EditorState, src: &str) {
|
||||||
|
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||||
|
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_with(body: &str) -> EditorState {
|
||||||
|
let s = EditorState::new_with_roots(&crate::iso::roots());
|
||||||
|
if !body.is_empty() {
|
||||||
|
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
|
||||||
|
}
|
||||||
|
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buffer_text(s: &EditorState) -> String {
|
||||||
|
let b: mlua::String = eval(
|
||||||
|
s,
|
||||||
|
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FID: FrontendId = FrontendId::LOCAL;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// A6 — one commit is one edit, one undo unit, one hook fan-out
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A6 — a multi-scalar commit is **one** edit and **one** undo unit.
|
||||||
|
///
|
||||||
|
/// This is the failure 1a exists to fix: as separate keypresses the same
|
||||||
|
/// grapheme is two edits, so one undo leaves half of it behind.
|
||||||
|
#[test]
|
||||||
|
fn a6_a_multi_scalar_commit_is_one_edit_and_one_undo_unit() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"_G.edits = 0
|
||||||
|
pmacs.hook.add('buffer.after-edit', function() _G.edits = _G.edits + 1 end)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A composed grapheme: base plus combining acute. Two scalars, one
|
||||||
|
// thing the user meant to type.
|
||||||
|
s.dispatch_text_input(FID, "e\u{301}");
|
||||||
|
assert_eq!(buffer_text(&s), "e\u{301}");
|
||||||
|
|
||||||
|
let edits: i64 = eval(&s, "return _G.edits");
|
||||||
|
assert_eq!(edits, 1, "one commit must fire ONE buffer.after-edit");
|
||||||
|
|
||||||
|
exec(&s, "pmacs.command.invoke('buffer.undo')");
|
||||||
|
assert_eq!(
|
||||||
|
buffer_text(&s),
|
||||||
|
"",
|
||||||
|
"one undo must remove the whole commit, not its last scalar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// §5 provenance — the single/multi split
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// §5 — a SINGLE-scalar commit is indistinguishable from a keypress, so
|
||||||
|
/// it must produce a real, consumable `TypedEditRecord`.
|
||||||
|
///
|
||||||
|
/// **Asserting `this_command` is not enough**, and that is the whole
|
||||||
|
/// point of this row: review round 2 found the code rotating the command
|
||||||
|
/// correctly while never completing the record, because arming and
|
||||||
|
/// completing are different steps and only the insert primitives
|
||||||
|
/// complete. `this_command` looked right and auto-pairing was broken.
|
||||||
|
/// So this consumes the record through the same seam `pair.lua` uses.
|
||||||
|
#[test]
|
||||||
|
fn single_scalar_text_input_produces_a_consumable_typed_edit_record() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
exec(&s, "pmacs.pair._capture_records = true");
|
||||||
|
|
||||||
|
s.dispatch_text_input(FID, "(");
|
||||||
|
|
||||||
|
let (cp, ch, clean, il): (i64, String, bool, i64) = eval(
|
||||||
|
&s,
|
||||||
|
"local r = pmacs.pair._last_record
|
||||||
|
return r.codepoint, r.char, r.clean, r.inserted_len",
|
||||||
|
);
|
||||||
|
assert_eq!(cp, 40, "exact codepoint for '('");
|
||||||
|
assert_eq!(ch, "(");
|
||||||
|
assert!(clean, "no intercept ran, so the effective triple is clean");
|
||||||
|
assert_eq!(il, 1);
|
||||||
|
|
||||||
|
let this_command: String = eval(&s, "return pmacs.editor.this_command() or ''");
|
||||||
|
assert_eq!(
|
||||||
|
this_command, "buffer.self-insert",
|
||||||
|
"and the command rotates, which is the half that already worked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5 — a MULTI-scalar commit is **not** a keystroke: it creates no
|
||||||
|
/// typed provenance and breaks the command chain.
|
||||||
|
#[test]
|
||||||
|
fn multi_scalar_text_input_creates_no_typed_provenance() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
exec(&s, "pmacs.pair._capture_records = true");
|
||||||
|
|
||||||
|
s.dispatch_text_input(FID, "e\u{301}");
|
||||||
|
|
||||||
|
let no_record: bool = eval(&s, "return pmacs.pair._last_record == nil");
|
||||||
|
assert!(
|
||||||
|
no_record,
|
||||||
|
"a multi-scalar commit must not forge a typed-edit record"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5 — a MULTI-scalar commit **breaks the command chain**, as a paste
|
||||||
|
/// does.
|
||||||
|
///
|
||||||
|
/// **The chain is PRIMED first, and that is what makes the row
|
||||||
|
/// discriminating.** Starting from a fresh editor the chain is already
|
||||||
|
/// empty, so an assertion that it is empty afterwards passes whether or
|
||||||
|
/// not `break_command_chain` is called — the first version of this row
|
||||||
|
/// did exactly that and would have survived deleting the call.
|
||||||
|
#[test]
|
||||||
|
fn multi_scalar_text_input_breaks_a_live_command_chain() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
|
||||||
|
// Prime it with 1a's OWN single-scalar path, which rotates to
|
||||||
|
// `buffer.self-insert`. A programmatic
|
||||||
|
// `pmacs.command.invoke('buffer.self-insert')` cannot prime it:
|
||||||
|
// rotation belongs to the dispatcher, and invoking the command
|
||||||
|
// directly deliberately never rotates or arms.
|
||||||
|
s.dispatch_text_input(FID, "x");
|
||||||
|
let primed: String = eval(&s, "return pmacs.editor.this_command() or ''");
|
||||||
|
assert_eq!(
|
||||||
|
primed, "buffer.self-insert",
|
||||||
|
"precondition: the chain is live before the commit"
|
||||||
|
);
|
||||||
|
|
||||||
|
s.dispatch_text_input(FID, "e\u{301}");
|
||||||
|
|
||||||
|
let after: Option<String> = eval(&s, "return pmacs.editor.this_command()");
|
||||||
|
assert_eq!(
|
||||||
|
after, None,
|
||||||
|
"a multi-scalar commit is not a command and must clear the chain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pairing consumer, end to end: a single-scalar `(` must auto-pair
|
||||||
|
/// exactly as a typed `(` does. This is the behaviour the missing record
|
||||||
|
/// silently disabled, stated in the terms a user would notice.
|
||||||
|
#[test]
|
||||||
|
fn single_scalar_text_input_auto_pairs_like_a_keypress() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
s.dispatch_text_input(FID, "(");
|
||||||
|
assert_eq!(
|
||||||
|
buffer_text(&s),
|
||||||
|
"()",
|
||||||
|
"auto-pairing consumes the typed-edit record; without one the \
|
||||||
|
closer is never inserted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// A7 — prompts consume scalars IN ORDER
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A7 — a prompt accumulates the scalars in order.
|
||||||
|
///
|
||||||
|
/// Order is the contract: a reversed or set-wise delivery would still
|
||||||
|
/// "consume" the text and would produce a different query.
|
||||||
|
#[test]
|
||||||
|
fn a7_a_prompt_consumes_scalars_in_order() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
"pmacs.minibuffer.read({ prompt = 'x: ', on_accept = function() end })",
|
||||||
|
);
|
||||||
|
assert!(s.core.borrow().minibuffer.is_active(), "prompt is up");
|
||||||
|
|
||||||
|
s.dispatch_text_input(FID, "abc");
|
||||||
|
|
||||||
|
let content: String = eval(&s, "return pmacs.minibuffer.contents() or ''");
|
||||||
|
assert_eq!(content, "abc", "in order, not reversed or reordered");
|
||||||
|
assert_eq!(
|
||||||
|
buffer_text(&s),
|
||||||
|
"",
|
||||||
|
"and the buffer underneath is untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// A9 — the cap rejects rather than truncates
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A9 — a payload at the cap is accepted whole. The complement of the
|
||||||
|
/// rejection row: a cap that refused its own boundary value would be
|
||||||
|
/// off by one in the direction nobody notices until a long IME commit
|
||||||
|
/// vanishes.
|
||||||
|
///
|
||||||
|
/// **The rejection half is witnessed where it is enforced** — at the
|
||||||
|
/// daemon boundary (`daemon.rs`, gated before any insert) and at the
|
||||||
|
/// producer (`AttachClient::send_text_input`, whose unit test lives
|
||||||
|
/// beside it in `pmacs-gpu`). Neither is reachable from an
|
||||||
|
/// `EditorState`, so asserting the constant here instead would be a row
|
||||||
|
/// that cannot fail for the right reason.
|
||||||
|
#[test]
|
||||||
|
fn a9_a_payload_at_the_cap_is_inserted_whole() {
|
||||||
|
let mut s = editor_with("");
|
||||||
|
let at_cap = "a".repeat(pmacs_protocol::TEXT_INPUT_MAX_BYTES);
|
||||||
|
s.dispatch_text_input(FID, &at_cap);
|
||||||
|
assert_eq!(
|
||||||
|
buffer_text(&s).len(),
|
||||||
|
pmacs_protocol::TEXT_INPUT_MAX_BYTES,
|
||||||
|
"the boundary value is legal and must land intact"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// A8 — a terminal receives RAW UTF-8, never bracketed paste
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A8, delivered rather than merely routed: the child process receives
|
||||||
|
/// the exact UTF-8 bytes, **while bracketed-paste mode is ENABLED**, and
|
||||||
|
/// no `ESC[200~` / `ESC[201~` markers.
|
||||||
|
///
|
||||||
|
/// **The enabled mode is the whole precondition.** With bracketed paste
|
||||||
|
/// off, "no markers" is true of every code path including a paste, so
|
||||||
|
/// the assertion would pass against the behaviour it exists to forbid.
|
||||||
|
/// The row therefore waits for the child's own `ESC[?2004h` to be
|
||||||
|
/// parsed, asserts the mode really is on, and only then types.
|
||||||
|
///
|
||||||
|
/// The contrast at the end is what makes it a discriminator: through the
|
||||||
|
/// same terminal in the same mode, a PASTE does get the markers. One
|
||||||
|
/// path bracketed and the other not, observed at the PTY.
|
||||||
|
#[test]
|
||||||
|
fn a8_a_terminal_receives_raw_utf8_with_bracketed_paste_enabled() {
|
||||||
|
use pmacs::terminal::TerminalSpec;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let sink = dir.path().join("received");
|
||||||
|
let sink_disp = sink.display().to_string();
|
||||||
|
|
||||||
|
let mut s = EditorState::new_with_roots(&crate::iso::roots());
|
||||||
|
|
||||||
|
// The child turns bracketed paste ON, then copies its stdin to a
|
||||||
|
// file so the test can read exactly what arrived on the PTY.
|
||||||
|
let script = format!("printf '\\033[?2004h'; exec cat > {sink_disp}");
|
||||||
|
let mut spec = TerminalSpec::new("/bin/sh");
|
||||||
|
spec.args = vec!["-c".into(), script];
|
||||||
|
spec.rows = 24;
|
||||||
|
spec.cols = 80;
|
||||||
|
let buffer_id = s
|
||||||
|
.terminal_manager
|
||||||
|
.borrow_mut()
|
||||||
|
.open(
|
||||||
|
spec,
|
||||||
|
&mut s.core.borrow_mut(),
|
||||||
|
&mut s.process_supervisor.borrow_mut(),
|
||||||
|
)
|
||||||
|
.expect("open terminal");
|
||||||
|
|
||||||
|
// Point this frontend's view at the terminal buffer, the way a
|
||||||
|
// daemon-side buffer switch does. Without it `active_terminal_key`
|
||||||
|
// returns `None`, the terminal branch is never taken, and the row
|
||||||
|
// fails for a setup reason rather than a behavioural one — which is
|
||||||
|
// exactly how it first failed.
|
||||||
|
let window_id = attach_terminal_view(&s, FID, buffer_id);
|
||||||
|
let key = pmacs::terminal::TerminalViewKey::new(FID, window_id, buffer_id);
|
||||||
|
// Precondition, asserted rather than assumed: this frontend's
|
||||||
|
// ACTIVE window shows the terminal buffer. A row that silently
|
||||||
|
// failed this would be testing the document path and reporting it
|
||||||
|
// as a terminal result.
|
||||||
|
{
|
||||||
|
let core = s.core.borrow();
|
||||||
|
let view = core.views.get(&FID).expect("view registered");
|
||||||
|
let active = core.windows.get(&view.active).expect("active window");
|
||||||
|
assert_eq!(active.buffer_id, buffer_id);
|
||||||
|
assert!(
|
||||||
|
s.terminal_manager.borrow().is_terminal(buffer_id),
|
||||||
|
"and the manager agrees it is a terminal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the child's mode-set to be parsed — a condition, not a
|
||||||
|
// sleep, so a slow machine waits longer rather than failing.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
s.tick_processes();
|
||||||
|
let on = s
|
||||||
|
.terminal_manager
|
||||||
|
.borrow()
|
||||||
|
.modes_for_view(key)
|
||||||
|
.is_some_and(|m| m.bracketed_paste);
|
||||||
|
if on {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"child never enabled bracketed paste; the precondition this \
|
||||||
|
row depends on was never established"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-byte and multi-scalar, so a byte-level mistake shows up.
|
||||||
|
let typed = "h\u{e9}llo\u{301}";
|
||||||
|
s.dispatch_text_input(FID, typed);
|
||||||
|
|
||||||
|
// Waits for AT LEAST the full payload, then asserts exact equality.
|
||||||
|
// Sound against a split write for a reason the contrast below does
|
||||||
|
// not share: the gate is a lower bound on length, so a partial
|
||||||
|
// delivery keeps waiting rather than being mistaken for a wrong
|
||||||
|
// answer — and the equality can still fail for the real reason,
|
||||||
|
// which a wait-for-exact-content loop could not.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let got = loop {
|
||||||
|
s.tick_processes();
|
||||||
|
let got = std::fs::read(&sink).unwrap_or_default();
|
||||||
|
if got.len() >= typed.len() {
|
||||||
|
break got;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"the child never received the typed text; got {got:?}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&got),
|
||||||
|
typed,
|
||||||
|
"the PTY must receive the exact UTF-8 that was typed"
|
||||||
|
);
|
||||||
|
let text = String::from_utf8_lossy(&got).into_owned();
|
||||||
|
assert!(
|
||||||
|
!text.contains("\u{1b}[200~") && !text.contains("\u{1b}[201~"),
|
||||||
|
"typed text must NOT be bracketed: {text:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The contrast, through the same terminal in the same mode: a paste
|
||||||
|
// IS bracketed. Without this the row above could pass because the
|
||||||
|
// mode was somehow inert rather than because the code is right.
|
||||||
|
assert!(
|
||||||
|
s.dispatch_paste(FID, b"pasted"),
|
||||||
|
"the terminal claims the paste"
|
||||||
|
);
|
||||||
|
//
|
||||||
|
// **Wait for the COMPLETE sequence, not the opening marker.** PTY
|
||||||
|
// delivery and the child's writes can split anywhere, so breaking
|
||||||
|
// as soon as `ESC[200~` appears and then requiring the payload and
|
||||||
|
// the closer is a race that fails on correct code — the closer may
|
||||||
|
// simply not have arrived yet. Polling for the whole string makes a
|
||||||
|
// partial write indistinguishable from "not yet", which is what it
|
||||||
|
// is. Same rule the vterm suite follows when it waits for `row19`
|
||||||
|
// rather than for a prefix of it.
|
||||||
|
let want = "\u{1b}[200~pasted\u{1b}[201~";
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
s.tick_processes();
|
||||||
|
let all = String::from_utf8_lossy(&std::fs::read(&sink).unwrap_or_default()).into_owned();
|
||||||
|
if all.contains(want) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"a paste through the same terminal must be bracketed on both \
|
||||||
|
sides; waited for {want:?}, saw {all:?}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a frontend view whose active window shows `buffer_id`.
|
||||||
|
fn attach_terminal_view(
|
||||||
|
state: &EditorState,
|
||||||
|
frontend_id: FrontendId,
|
||||||
|
buffer_id: pmacs::buffer::BufferId,
|
||||||
|
) -> pmacs::window::WindowId {
|
||||||
|
use pmacs::window::{FrontendView, Layout, Window, WindowId};
|
||||||
|
let mut core = state.core.borrow_mut();
|
||||||
|
let text_view = {
|
||||||
|
let registry = core.registry.clone();
|
||||||
|
let registry = registry.borrow();
|
||||||
|
let buffer = registry.get(buffer_id).expect("buffer present");
|
||||||
|
pmacs::text_view::TextView::new(buffer)
|
||||||
|
};
|
||||||
|
let window_id = WindowId::next();
|
||||||
|
core.windows
|
||||||
|
.insert(window_id, Window::new(window_id, buffer_id, text_view));
|
||||||
|
core.register_frontend_view(
|
||||||
|
frontend_id,
|
||||||
|
FrontendView {
|
||||||
|
layout: Layout::single(window_id),
|
||||||
|
active: window_id,
|
||||||
|
fold_projection: true,
|
||||||
|
panel_capable: true,
|
||||||
|
frame_geometry: None,
|
||||||
|
panel_hidden: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[path = "common/iso.rs"]
|
||||||
|
mod iso;
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
//! GUI arc Stage 1a — the `TextInput` wire gate, against a real daemon.
|
||||||
|
//!
|
||||||
|
//! Separate from `gui_stage1a_acceptance` because these rows need a
|
||||||
|
//! live daemon and a negotiated session; that suite is in-process.
|
||||||
|
//!
|
||||||
|
//! **The claim under test is a REFUSAL**, which is the hardest kind to
|
||||||
|
//! witness honestly: "nothing happened" is also what a broken test,
|
||||||
|
//! a dead daemon or a dropped connection look like. Every row here
|
||||||
|
//! therefore pairs the refusal with a positive control on the same
|
||||||
|
//! session — something that *does* take effect — so silence can only
|
||||||
|
//! mean the gate fired.
|
||||||
|
|
||||||
|
#![cfg(feature = "crdt")]
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pmacs_protocol::{
|
||||||
|
ADVERTISED_PROTOCOL_VERSION, AttachRequest, CellSize, FrontendCapabilities, FrontendEvent,
|
||||||
|
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SessionBootstrapRequest,
|
||||||
|
TEXT_INPUT_MIN_VERSION, read_message, write_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "common/mod.rs"]
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
fn semantic_caps() -> FrontendCapabilities {
|
||||||
|
FrontendCapabilities {
|
||||||
|
synchronized_output: false,
|
||||||
|
unicode_smp: true,
|
||||||
|
true_color: true,
|
||||||
|
mouse: false,
|
||||||
|
bracketed_paste: false,
|
||||||
|
terminal_kind: Some("stage1a".into()),
|
||||||
|
multi_frontend: true,
|
||||||
|
crdt_replica: true,
|
||||||
|
semantic_render: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a semantic session that counter-offers `offer`.
|
||||||
|
fn attach_semantic(
|
||||||
|
daemon: &common::daemon::TestDaemon,
|
||||||
|
offer: u32,
|
||||||
|
) -> (UnixStream, pmacs_protocol::FrontendId) {
|
||||||
|
let mut stream = daemon.connect();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
|
||||||
|
assert_eq!(
|
||||||
|
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
|
||||||
|
"the server-first Hello stays at the compatibility baseline"
|
||||||
|
);
|
||||||
|
let fid = hello.assigned_frontend_id;
|
||||||
|
write_message(
|
||||||
|
&mut stream,
|
||||||
|
&AttachRequest {
|
||||||
|
protocol_version: offer,
|
||||||
|
frontend_capabilities: semantic_caps(),
|
||||||
|
initial_size: CellSize::new(24, 80),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("write AttachRequest");
|
||||||
|
write_message(&mut stream, &SessionBootstrapRequest::default()).expect("write bootstrap");
|
||||||
|
(stream, fid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump<T>(
|
||||||
|
stream: &mut UnixStream,
|
||||||
|
what: &str,
|
||||||
|
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
|
||||||
|
) -> T {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match read_message::<InstanceMessage>(stream) {
|
||||||
|
Ok(msg) => {
|
||||||
|
if let Some(found) = want(&msg) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => panic!("{what}: read stopped: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for {what}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_text_input(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, text: &str) {
|
||||||
|
write_message(
|
||||||
|
stream,
|
||||||
|
&FrontendEvent::TextInput {
|
||||||
|
frontend_id: fid,
|
||||||
|
text: text.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("write TextInput");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key) {
|
||||||
|
write_message(
|
||||||
|
stream,
|
||||||
|
&FrontendEvent::Key(KeyEvent {
|
||||||
|
frontend_id: fid,
|
||||||
|
key,
|
||||||
|
mods: Modifiers::NONE,
|
||||||
|
timestamp_ns: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("write key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The discriminating witness for the inbound gate.** A session that
|
||||||
|
/// negotiated v23 can still ENCODE `TextInput` — it is built from this
|
||||||
|
/// same crate — so the daemon must refuse it on the authenticated
|
||||||
|
/// session's negotiated version rather than trusting the producer to
|
||||||
|
/// withhold.
|
||||||
|
///
|
||||||
|
/// The positive control is what makes the refusal legible: the SAME
|
||||||
|
/// session then sends an ordinary `Key`, and that must take effect. So
|
||||||
|
/// the session is alive, the stream is synchronized and the daemon is
|
||||||
|
/// listening — silence about the `TextInput` is the gate, not the
|
||||||
|
/// plumbing.
|
||||||
|
#[test]
|
||||||
|
fn a_v23_session_cannot_drive_an_edit_through_text_input() {
|
||||||
|
assert_eq!(
|
||||||
|
TEXT_INPUT_MIN_VERSION, 24,
|
||||||
|
"this row is written against the v24 floor"
|
||||||
|
);
|
||||||
|
let daemon = common::daemon::TestDaemon::spawn();
|
||||||
|
let (mut stream, fid) = attach_semantic(&daemon, TEXT_INPUT_MIN_VERSION - 1);
|
||||||
|
|
||||||
|
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||||
|
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refused: encoded by a peer that never declared v24.
|
||||||
|
send_text_input(&mut stream, fid, "REFUSED");
|
||||||
|
|
||||||
|
// The positive control, on the same session and after it.
|
||||||
|
send_key(&mut stream, fid, Key::Char('k'));
|
||||||
|
|
||||||
|
// The first edit that reaches this session must be the CONTROL's,
|
||||||
|
// never the refused text. Ordering carries the proof: the daemon
|
||||||
|
// processes a session's events in order, so the control's edit
|
||||||
|
// arriving with no preceding `REFUSED` edit means the TextInput was
|
||||||
|
// dropped rather than merely slow.
|
||||||
|
let op = pump(&mut stream, "the control's edit", |msg| match msg {
|
||||||
|
InstanceMessage::CrdtOp {
|
||||||
|
buffer_id: b, op, ..
|
||||||
|
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let text = String::from_utf8_lossy(&op).into_owned();
|
||||||
|
assert!(
|
||||||
|
!text.contains("REFUSED"),
|
||||||
|
"a v23 session must not be able to insert through TextInput; got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complement, so the row above cannot pass because `TextInput` is
|
||||||
|
/// broken outright: the SAME traffic on a v24 session **does** edit.
|
||||||
|
#[test]
|
||||||
|
fn a_v24_session_can_drive_an_edit_through_text_input() {
|
||||||
|
let daemon = common::daemon::TestDaemon::spawn();
|
||||||
|
let (mut stream, fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||||
|
|
||||||
|
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||||
|
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
send_text_input(&mut stream, fid, "ACCEPTED");
|
||||||
|
|
||||||
|
let op = pump(&mut stream, "the TextInput edit", |msg| match msg {
|
||||||
|
InstanceMessage::CrdtOp {
|
||||||
|
buffer_id: b, op, ..
|
||||||
|
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let text = String::from_utf8_lossy(&op).into_owned();
|
||||||
|
assert!(
|
||||||
|
text.contains("ACCEPTED"),
|
||||||
|
"a v24 session must be able to insert through TextInput; got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -804,11 +804,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
||||||
// three lines on purpose. The ceiling assertion is the load-bearing
|
// three lines on purpose. The ceiling assertion is the load-bearing
|
||||||
// one — it says the supported set ENDS here, which is what makes an
|
// one — it says the supported set ENDS here, which is what makes an
|
||||||
// accidentally-widened set a failure rather than a silent pass.
|
// accidentally-widened set a failure rather than a silent pass.
|
||||||
assert_eq!(PROTOCOL_VERSION, 23);
|
assert_eq!(PROTOCOL_VERSION, 24);
|
||||||
for version in 6..=23 {
|
for version in 6..=24 {
|
||||||
assert!(is_supported_protocol_version(version));
|
assert!(is_supported_protocol_version(version));
|
||||||
}
|
}
|
||||||
assert!(!is_supported_protocol_version(24));
|
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
||||||
let sample = InstanceMessage::StatuslineSegments {
|
let sample = InstanceMessage::StatuslineSegments {
|
||||||
buffer_id: BufferId::from_raw(9),
|
buffer_id: BufferId::from_raw(9),
|
||||||
left: vec![StatuslineSegment {
|
left: vec![StatuslineSegment {
|
||||||
|
|
|
||||||
|
|
@ -888,10 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
|
||||||
panic!("timed out waiting for {what}");
|
panic!("timed out waiting for {what}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tripwire: a wire bump must be a conscious edit here. v23 is
|
// Tripwire: a wire bump must be a conscious edit here. v24 is
|
||||||
// `MinibufferPromptRows` (Discovery Stage 2); v22 was
|
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
|
||||||
// `LineWrapFacts` (long-lines Stage 3).
|
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||||
assert_eq!(PROTOCOL_VERSION, 23);
|
assert_eq!(PROTOCOL_VERSION, 24);
|
||||||
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
||||||
&[
|
&[
|
||||||
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue