fix(panel): the projection clamp, a reachable P10, and a disposition that cannot lie
Answers review of 2ea39aa. Two of my claims were wrong and the code
disproved them.
WIDE PANELS HAD DEAD POINTER INPUT. A panel deliberately does not
inherit the terminal's per-axis PTY caps (Bet B5'), and the renderer
clamps through terminal_projection_size so a band wider than
MAX_TERMINAL_COLS paints correctly. Pointer routing passed the RAW panel
width, and view_status_for_size refuses anything over the cap --- so on
exactly those panels every click inside the visible terminal resolved to
None while the band looked perfectly normal. Routing and the recorded
viewport now go through the same clamp, with P12 as a POSITIVE control
at MAX_TERMINAL_COLS + 128.
P10 IS REACHABLE, and my note saying otherwise was wrong. I claimed
anchor_at resolves every in-grid cell of a live view, "measured, not
assumed". I had measured ROWS and generalised to cells. anchor_at
refuses coord.col >= row.cells.len(), and the fixture's band is 80
columns over a 20-column child, so columns 20..79 are painted padding
inside accepted content. The row exists now and the begin_selection gate
is witnessed rather than excused.
THE DISPOSITION IS AN ENUM. As {outcome, Option<target>} the invalid
pair --- refused, yet carrying a target --- stayed representable inside
editor.rs, so my "the type makes it impossible" was also wrong. Refused
now holds no target at all. ResolvedPanelTarget is public as a type and
opaque as a value: every field stays private, so the daemon hands the
disposition back rather than reading a derivation out of it.
P2 also now asserts what it observes: the classification is Refused, and
focus and terminal-controller ownership are preserved. A misclassified
press focuses the panel and claims the controller BEFORE its
out-of-range anchor fails, so byte and latch assertions alone stayed
green through exactly that bug.
MY MUTATION HARNESS WAS READING SUCCESS FROM SILENCE. `cargo test --lib
"r4_p\|g5k_"` is a literal substring filter, not a regex: it matched
nothing and ran ZERO tests, and I read the absence of failures as
"the mutation did not bite". One whole round proved nothing. The harness
now prints the run count and says so loudly when it is zero. Re-run
properly, all three fixes bite their named rows.
Also updates the ledger's witness list, which omitted P2, P9, P11, the
recorded viewport and the exact-byte strengthening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
2ea39aaa57
commit
9028e1b172
|
|
@ -295,10 +295,26 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
|
|||
modes per event — G5k's named mutation. The press now records
|
||||
`PanelGestureDomain` (document / terminal-child-with-encoding /
|
||||
terminal-local) and every tail and completion follows it.
|
||||
- **Witnesses: G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8.** Each
|
||||
reads a TARGET EFFECT — the child's byte stream, the terminal drag
|
||||
state, or the document selection — never the latch alone. Each
|
||||
bites its own mutation, including G5k's verbatim.
|
||||
- **LANDED: the record is SELF-CONTAINED.** `TerminalLocal` carries
|
||||
the accepted content **viewport** (ambient geometry is `None`
|
||||
exactly when a hidden panel needs completing); a press that anchors
|
||||
nothing no longer arms on either target; and pointer routing goes
|
||||
through the renderer's own `terminal_projection_size` clamp — a
|
||||
band wider than `MAX_TERMINAL_COLS` painted fine while every click
|
||||
inside it resolved to nothing.
|
||||
- **LANDED: `PanelPointerDisposition` is an ENUM.** As
|
||||
`{outcome, Option<target>}` the invalid pair — refused, yet
|
||||
carrying a target — stayed representable inside `editor.rs`. Now
|
||||
`Refused` holds no target at all.
|
||||
- **Witnesses: G5k(a)–(d), P1, P2, P3 both legs, P4, P5, P7, P8, P9,
|
||||
P10, P11, P12.** Each reads a TARGET EFFECT — the child's byte
|
||||
stream (**exact bytes**, not a count), the terminal drag state, the
|
||||
document selection, or focus and controller ownership — never the
|
||||
latch alone. Each bites its own mutation, including G5k's verbatim.
|
||||
- **Every fixture asserts its own precondition** (the disposition is
|
||||
`Accepted`, or is `Refused`) because four rows in these rounds
|
||||
passed vacuously: cells that were out of grid, or that clamped to
|
||||
byte 0, exercised a refusal instead of the path they named.
|
||||
- **REMAINING, in order:** task 18's pending-release slot and drains,
|
||||
task 19's four stranding transitions, then the full head-exact gate
|
||||
and the PR.
|
||||
|
|
|
|||
171
src/daemon.rs
171
src/daemon.rs
|
|
@ -8332,25 +8332,130 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// P10 IS DELIBERATELY ABSENT, and this note is why.
|
||||
//
|
||||
// The press path also gates arming on `begin_selection` actually
|
||||
// starting a drag, so a local terminal press that begins nothing
|
||||
// records nothing. That gate has NO WITNESS because it has no
|
||||
// reachable false branch through the daemon: `begin_selection`
|
||||
// returns `false` only when the session is missing or the cell maps
|
||||
// to no retained row, and by the time the daemon reaches the press
|
||||
// path `classify_panel_pointer` has already established that the
|
||||
// buffer IS the side window's live terminal, while `anchor_at`
|
||||
// resolves every in-grid cell of a live view — measured, not
|
||||
// assumed: a press at every content row of the 4-row fixture
|
||||
// anchors.
|
||||
//
|
||||
// The gate is kept as insurance against a future view that can
|
||||
// refuse, and it is recorded as UNWITNESSED rather than covered by
|
||||
// a row that would pass whether or not the gate existed. A row at
|
||||
// an out-of-grid cell looks like it tests this and does not: such a
|
||||
// press is `Refused` long before the local path.
|
||||
/// P10 — a LOCAL terminal press that begins no drag does not arm.
|
||||
///
|
||||
/// **The panel is wider than its terminal.** The fixture's band is
|
||||
/// 80 columns while the child's screen is 20, so columns 20..79 are
|
||||
/// painted padding: inside accepted panel content, and behind no
|
||||
/// terminal cell. `anchor_at` refuses them
|
||||
/// (`coord.col >= row.cells.len()`), `begin_selection` returns
|
||||
/// `false`, and nothing begins.
|
||||
///
|
||||
/// An earlier round recorded this gate as UNWITNESSED, claiming
|
||||
/// `anchor_at` resolves every in-grid cell of a live view. That was
|
||||
/// measured on ROWS and generalised to cells, which is a different
|
||||
/// statement and a false one.
|
||||
#[test]
|
||||
fn r4_p10_a_local_press_that_begins_no_drag_does_not_arm() {
|
||||
let fid = FrontendId(803);
|
||||
let (mut editor, mut states, mut render, panel, buffer_id, epochs) =
|
||||
terminal_panel_session(fid, false);
|
||||
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
|
||||
let none = pmacs_protocol::Modifiers::default();
|
||||
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
|
||||
// Column 20 of an 80-column band over a 20-column child.
|
||||
let padding = pmacs_protocol::CellCoord::new(1, 20);
|
||||
assert_eq!(
|
||||
editor
|
||||
.classify_panel_pointer(fid, buffer_id, padding, press)
|
||||
.outcome(),
|
||||
crate::editor::PanelPointerOutcome::Accepted,
|
||||
"fixture: the cell is ACCEPTED content, so a refusal cannot \
|
||||
be what this row observes"
|
||||
);
|
||||
|
||||
send_panel(
|
||||
&mut editor,
|
||||
&mut states,
|
||||
&mut render,
|
||||
fid,
|
||||
epochs,
|
||||
buffer_id,
|
||||
padding,
|
||||
press,
|
||||
none,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!editor
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.view_is_dragging_for_test(key),
|
||||
"fixture: no local drag began --- the cell is painted padding"
|
||||
);
|
||||
assert!(
|
||||
!states[&fid].has_accepted_gesture(),
|
||||
"so nothing may be armed: the record would name a drag that \
|
||||
does not exist, and its completion would have nothing to \
|
||||
finish"
|
||||
);
|
||||
}
|
||||
|
||||
/// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input.
|
||||
///
|
||||
/// A panel deliberately does not inherit the terminal's per-axis PTY
|
||||
/// caps (Bet B5'): a 4K surface at a small font is legitimately
|
||||
/// wider than `MAX_TERMINAL_COLS`, and the renderer clamps through
|
||||
/// `terminal_projection_size` so the band paints. Pointer routing
|
||||
/// passed the RAW panel width, and `view_status_for_size` refuses
|
||||
/// anything over the cap — so on exactly those panels a click
|
||||
/// squarely inside the visible terminal resolved to nothing and the
|
||||
/// whole band was dead to the mouse while looking perfectly normal.
|
||||
///
|
||||
/// A POSITIVE control: the gesture must land, not merely fail
|
||||
/// safely.
|
||||
#[test]
|
||||
fn r4_p12_a_panel_wider_than_the_terminal_cap_still_routes_pointer_input() {
|
||||
let fid = FrontendId(805);
|
||||
let (mut editor, mut states, mut render, panel, buffer_id, _epochs) =
|
||||
terminal_panel_session(fid, true);
|
||||
|
||||
// Re-declare the surface far wider than MAX_TERMINAL_COLS.
|
||||
let wide = u32::from(crate::terminal::MAX_TERMINAL_COLS) + 128;
|
||||
editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, wide));
|
||||
let epochs = shipped_declaration(&editor, fid, &mut states);
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let grid = core.panel_grid_size(fid).expect("a live panel grid");
|
||||
assert!(
|
||||
grid.cols > u32::from(crate::terminal::MAX_TERMINAL_COLS),
|
||||
"fixture: the band must actually exceed the terminal cap, \
|
||||
got {} columns",
|
||||
grid.cols
|
||||
);
|
||||
}
|
||||
|
||||
let cell = pmacs_protocol::CellCoord::new(1, 2);
|
||||
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
|
||||
let none = pmacs_protocol::Modifiers::default();
|
||||
let _ = child_stream(&editor);
|
||||
|
||||
send_panel(
|
||||
&mut editor,
|
||||
&mut states,
|
||||
&mut render,
|
||||
fid,
|
||||
epochs,
|
||||
buffer_id,
|
||||
cell,
|
||||
press,
|
||||
none,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
child_stream(&editor),
|
||||
vec![SGR_PRESS_1_2.to_vec()],
|
||||
"the press must reach the child on a wide band: routing has \
|
||||
to use the SAME projection clamp the renderer uses, or the \
|
||||
two disagree and the panel paints while ignoring the mouse"
|
||||
);
|
||||
assert!(
|
||||
states[&fid].has_accepted_gesture(),
|
||||
"and it must arm --- this is a positive control, so failing \
|
||||
safely is still failing"
|
||||
);
|
||||
let _ = panel;
|
||||
}
|
||||
|
||||
/// P11 — a recorded LOCAL completion still runs with the panel
|
||||
/// HIDDEN.
|
||||
|
|
@ -8432,6 +8537,19 @@ mod tests {
|
|||
pmacs_protocol::CellCoord::new(grid.rows, 0)
|
||||
};
|
||||
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
|
||||
let active_before = editor.core.borrow().active_frontend;
|
||||
let controller_before = editor
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.controller_view_for_frontend(fid);
|
||||
assert_eq!(
|
||||
editor
|
||||
.classify_panel_pointer(fid, buffer_id, outside, press)
|
||||
.outcome(),
|
||||
crate::editor::PanelPointerOutcome::Refused,
|
||||
"fixture: this press really is refused --- asserted, because \
|
||||
every effect assertion below is vacuous if it is not"
|
||||
);
|
||||
|
||||
send_panel(
|
||||
&mut editor,
|
||||
|
|
@ -8450,6 +8568,21 @@ mod tests {
|
|||
"a refused press must send the child NOTHING --- a press it \
|
||||
receives is one it will expect a release for"
|
||||
);
|
||||
assert_eq!(
|
||||
editor.core.borrow().active_frontend,
|
||||
active_before,
|
||||
"and it must not ACTIVATE the panel: a misclassified press \
|
||||
focuses before its out-of-range anchor fails, so byte and \
|
||||
latch assertions alone stay green while focus has moved"
|
||||
);
|
||||
assert_eq!(
|
||||
editor
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.controller_view_for_frontend(fid),
|
||||
controller_before,
|
||||
"and it must not claim the terminal CONTROLLER"
|
||||
);
|
||||
assert!(
|
||||
!editor
|
||||
.terminal_manager
|
||||
|
|
|
|||
|
|
@ -526,21 +526,41 @@ pub enum TerminalGestureRoute {
|
|||
/// authority for that derivation; handing the daemon a bare outcome
|
||||
/// and letting it re-derive chrome or target kind would be the hole
|
||||
/// §5b closed, reopened beside the dispatcher.
|
||||
pub struct PanelPointerDisposition {
|
||||
outcome: PanelPointerOutcome,
|
||||
resolved: Option<ResolvedPanelTarget>,
|
||||
pub enum PanelPointerDisposition {
|
||||
/// Not addressable as this panel. **Carries no target**, which is
|
||||
/// the point: as two independent fields, `Refused` plus a resolved
|
||||
/// target stayed representable inside this module, and the only
|
||||
/// thing preventing a refused gesture from reaching a target was
|
||||
/// that no code currently built that pair.
|
||||
Refused,
|
||||
/// Claimed by this panel, but deliberately not a content gesture.
|
||||
Consumed(ResolvedPanelTarget),
|
||||
/// A content gesture for the resolved target.
|
||||
Accepted(ResolvedPanelTarget),
|
||||
}
|
||||
|
||||
impl PanelPointerDisposition {
|
||||
/// The disposition, for the daemon's lifecycle table.
|
||||
#[must_use]
|
||||
pub fn outcome(&self) -> PanelPointerOutcome {
|
||||
self.outcome
|
||||
match self {
|
||||
Self::Refused => PanelPointerOutcome::Refused,
|
||||
Self::Consumed(_) => PanelPointerOutcome::Consumed,
|
||||
Self::Accepted(_) => PanelPointerOutcome::Accepted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel target a disposition resolved to, derived once.
|
||||
struct ResolvedPanelTarget {
|
||||
///
|
||||
/// **Public as a type, opaque as a value.** It has to be nameable
|
||||
/// because `PanelPointerDisposition`'s variants carry it, but every
|
||||
/// field stays private: the daemon receives a disposition and hands it
|
||||
/// back, and cannot read the side window, the target kind or the
|
||||
/// content bounds out of it. That is Q#BP-R4's first fixed fact — the
|
||||
/// editor is the only authority for the derivation — enforced by
|
||||
/// visibility rather than by convention.
|
||||
pub struct ResolvedPanelTarget {
|
||||
side: WindowId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
is_terminal: bool,
|
||||
|
|
@ -2963,10 +2983,7 @@ impl EditorState {
|
|||
) -> PanelPointerDisposition {
|
||||
use pmacs_protocol::MouseKind as PKind;
|
||||
|
||||
let refused = PanelPointerDisposition {
|
||||
outcome: PanelPointerOutcome::Refused,
|
||||
resolved: None,
|
||||
};
|
||||
let refused = PanelPointerDisposition::Refused;
|
||||
|
||||
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
|
||||
return refused;
|
||||
|
|
@ -2994,13 +3011,13 @@ impl EditorState {
|
|||
side
|
||||
};
|
||||
|
||||
let resolved = Some(ResolvedPanelTarget {
|
||||
let resolved = ResolvedPanelTarget {
|
||||
side,
|
||||
buffer_id,
|
||||
is_terminal,
|
||||
content_rows,
|
||||
cols: size.cols,
|
||||
});
|
||||
};
|
||||
let on_chrome = coord.row >= content_rows;
|
||||
let is_wheel = matches!(
|
||||
kind,
|
||||
|
|
@ -3009,7 +3026,7 @@ impl EditorState {
|
|||
|
||||
// Everything below is CLAIMED by this panel — the cell is ours —
|
||||
// and the only question left is whether it is a content gesture.
|
||||
let outcome = if on_chrome {
|
||||
let consumed = if on_chrome {
|
||||
// Q#BP-R2, step 3 of the ordering: a terminal panel's chrome
|
||||
// wheel is consumed BEFORE focus, before `active_frontend`,
|
||||
// and before any controller claim. Placing it after
|
||||
|
|
@ -3022,16 +3039,16 @@ impl EditorState {
|
|||
// rule — presses and motion are reserved, while `Up` and the
|
||||
// wheel fall through, because an `Up` must still terminate a
|
||||
// gesture begun in content and a chrome wheel still scrolls.
|
||||
if is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) {
|
||||
let _ = is_wheel;
|
||||
PanelPointerOutcome::Consumed
|
||||
} else {
|
||||
PanelPointerOutcome::Accepted
|
||||
}
|
||||
let _ = is_wheel;
|
||||
is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move)
|
||||
} else {
|
||||
PanelPointerOutcome::Accepted
|
||||
false
|
||||
};
|
||||
PanelPointerDisposition { outcome, resolved }
|
||||
if consumed {
|
||||
PanelPointerDisposition::Consumed(resolved)
|
||||
} else {
|
||||
PanelPointerDisposition::Accepted(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a classified panel gesture to its target (Q#BP-R4).
|
||||
|
|
@ -3070,10 +3087,11 @@ impl EditorState {
|
|||
) -> Option<PanelGestureDomain> {
|
||||
use pmacs_protocol::MouseKind as PKind;
|
||||
|
||||
if disposition.outcome != PanelPointerOutcome::Accepted {
|
||||
// Only `Accepted` carries a target into an effect, and the type
|
||||
// is what enforces it: there is no `Refused` value holding one.
|
||||
let PanelPointerDisposition::Accepted(target) = disposition else {
|
||||
return None;
|
||||
}
|
||||
let target = disposition.resolved.as_ref()?;
|
||||
};
|
||||
|
||||
let activates = if target.is_terminal {
|
||||
!matches!(kind, PKind::Move)
|
||||
|
|
@ -3091,7 +3109,17 @@ impl EditorState {
|
|||
// document terminal. The viewport is `content_rows`, never the
|
||||
// full grid: passing the frame would make the mode line a child
|
||||
// cell and put every clamp one row out.
|
||||
let viewport = CellSize::new(target.content_rows, target.cols);
|
||||
// THROUGH THE RENDERER'S OWN CLAMP. A panel deliberately does
|
||||
// not inherit the terminal's per-axis PTY caps (Bet B5'), so
|
||||
// a 4K surface at a small font is legitimately wider than
|
||||
// `MAX_TERMINAL_COLS` — and `view_status_for_size` refuses
|
||||
// anything over that cap. Passing the raw panel width made
|
||||
// every gesture on such a panel resolve to `None`: a click
|
||||
// squarely inside the visible terminal did nothing at all,
|
||||
// while the same panel rendered fine because projection
|
||||
// clamps and pointer routing did not agree.
|
||||
let viewport =
|
||||
terminal_projection_size(CellSize::new(target.content_rows, target.cols));
|
||||
let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id);
|
||||
return match self.apply_terminal_gesture(
|
||||
key,
|
||||
|
|
|
|||
Loading…
Reference in New Issue