fix(gpu): gutter click classification + fit guard; test v14 wire round-trip
Three correctness findings from the sub-arc 3 review:
1. (F1) GPU gutter clicks weren't classified before text hit-testing — the
hit path just subtracted `text_left()` and called `buffer.hit()`, so a
click in the gutter band fed glyphon a negative x (undefined) and gave
future gutter markers no stable seam. Extracted `gutter_aware_rel_x`: a
click left of the text origin clamps to `0.0` (the line start), mirroring
the TUI's saturate-to-column-0 affordance. The hit path now branches on
it — the seam a future marker would hook.
2. (F2) The GPU had no fit guard when the gutter consumed the text width.
The TUI drops the gutter for a too-narrow window; the GPU always grew
`text_left()` and `text_bounds_right()` floored against `TEXT_LEFT`, so a
narrow window or very large file could produce `left >= right` (blank /
undefined render). `gutter_width_px` now drops the gutter when it would
leave less than `MIN_TEXT_WIDTH_PX` of text past `TEXT_LEFT`.
3. (F3) The v14 `LineNumbers { mode }` shape had no direct postcard
round-trip (only the version pin + daemon gate). Added one covering all
four `LineNumberMode` variants, so a future enum reorder can't silently
shift the wire.
Tests: `gutter_aware_rel_x` clamps the band (and passes through with the
gutter off); a 60px window drops the gutter while an 800px one keeps it;
all four modes round-trip. fmt + clippy --all-targets clean both flavors +
gpu; 1447 lib + 12 protocol + 57 pmacs-gpu tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
parent
ccb0ff6021
commit
1670233057
|
|
@ -102,6 +102,11 @@ const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6;
|
|||
/// `W` its width; it spans the full line height.
|
||||
const GUTTER_SIGN_X: f32 = 4.0;
|
||||
const GUTTER_SIGN_W: f32 = 4.0;
|
||||
/// Minimum text-area width (px) the gutter must leave. If reserving the
|
||||
/// gutter would crowd the text below this, the gutter is dropped for the
|
||||
/// frame — the GPU mirror of the TUI's too-narrow-window disable, so a
|
||||
/// narrow window or a very large file can never force `left >= right`.
|
||||
const MIN_TEXT_WIDTH_PX: f32 = 48.0;
|
||||
const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92];
|
||||
const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82];
|
||||
const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18];
|
||||
|
|
@ -2818,7 +2823,19 @@ impl State {
|
|||
return 0.0;
|
||||
}
|
||||
let lines = self.current_line_starts.len().max(1);
|
||||
decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX
|
||||
let want = decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX;
|
||||
// Fit guard (mirrors the TUI's too-narrow disable): never reserve so
|
||||
// much gutter that the text area collapses. `text_bounds_right` is
|
||||
// the text clip edge (minimap-aware); if the gutter would leave less
|
||||
// than `MIN_TEXT_WIDTH_PX` past `TEXT_LEFT`, drop it this frame
|
||||
// rather than shift `text_left` to or past the clip and render into
|
||||
// a degenerate `left >= right` rectangle.
|
||||
let avail = self.text_bounds_right() as f32 - TEXT_LEFT;
|
||||
if want + MIN_TEXT_WIDTH_PX > avail {
|
||||
0.0
|
||||
} else {
|
||||
want
|
||||
}
|
||||
}
|
||||
|
||||
/// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every
|
||||
|
|
@ -2882,6 +2899,22 @@ impl State {
|
|||
/// line) → projected byte → run map → slice byte → + `vstart`.
|
||||
/// `None` when no buffer is attached or the position is outside
|
||||
/// anything hit-testable.
|
||||
/// Text-relative x for hit testing, classifying the gutter band first
|
||||
/// (UX gutter, Q#UX6). A click left of the text origin (`raw_x < 0`,
|
||||
/// i.e. inside the gutter) is not a text hit — it clamps to `0.0`, the
|
||||
/// line start, rather than feeding glyphon a negative x (undefined).
|
||||
/// Mirrors the TUI's saturate-to-column-0 affordance and is the stable
|
||||
/// seam a future gutter marker would branch on instead of relying on
|
||||
/// glyphon's negative-x edge behavior.
|
||||
fn gutter_aware_rel_x(&self, x: f64) -> f32 {
|
||||
let raw_x = x as f32 - self.text_left();
|
||||
if self.line_numbers.is_on() && raw_x < 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
raw_x
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option<u64> {
|
||||
self.current_buffer_id?;
|
||||
if self.hit_map_dirty {
|
||||
|
|
@ -2900,7 +2933,7 @@ impl State {
|
|||
self.projected_line_starts = projected_line_starts;
|
||||
self.hit_map_dirty = false;
|
||||
}
|
||||
let rel_x = x as f32 - self.text_left();
|
||||
let rel_x = self.gutter_aware_rel_x(x);
|
||||
let rel_y = y as f32 - TEXT_TOP;
|
||||
let cursor = self.buffer.hit(rel_x, rel_y)?;
|
||||
let line_start = *self.projected_line_starts.get(cursor.line)?;
|
||||
|
|
@ -7517,4 +7550,55 @@ mod tests {
|
|||
"relative numbering must differ from absolute ({differing} bytes differ)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_aware_rel_x_clamps_the_gutter_band() {
|
||||
// F1: a click in the gutter band (left of the text origin) clamps
|
||||
// to the line start (rel_x 0), never a negative x into glyphon.
|
||||
let text = "alpha\nbeta\ngamma\n";
|
||||
let Some(mut s) = headless_or_skip(400, 300, text) else {
|
||||
return;
|
||||
};
|
||||
s.line_numbers = LineNumberMode::Absolute;
|
||||
let text_left = f64::from(s.text_left());
|
||||
assert!(text_left > f64::from(TEXT_LEFT), "the gutter is present");
|
||||
|
||||
// Inside the gutter band and at the exact origin → clamped to 0.
|
||||
assert!(s.gutter_aware_rel_x(text_left - 4.0).abs() < f32::EPSILON);
|
||||
assert!(s.gutter_aware_rel_x(text_left).abs() < f32::EPSILON);
|
||||
// Well into the text → a positive text-relative x.
|
||||
assert!(s.gutter_aware_rel_x(text_left + 40.0) > 0.0);
|
||||
|
||||
// With the gutter off there's no band, so a left-of-origin x passes
|
||||
// through negative (the pre-gutter behavior is unchanged).
|
||||
s.line_numbers = LineNumberMode::Off;
|
||||
assert!(s.gutter_aware_rel_x(f64::from(TEXT_LEFT) - 4.0) < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_window_drops_the_gutter() {
|
||||
// F2: a window too narrow to fit the gutter + a minimum text area
|
||||
// drops the gutter for the frame (no `left >= right`), mirroring the
|
||||
// TUI. A wide window keeps it.
|
||||
let text = "l1\nl2\nl3\n";
|
||||
let Some(mut narrow) = headless_or_skip(60, 200, text) else {
|
||||
return;
|
||||
};
|
||||
narrow.line_numbers = LineNumberMode::Absolute;
|
||||
assert!(
|
||||
narrow.gutter_width_px() < f32::EPSILON,
|
||||
"a 60px window can't fit gutter + min text → gutter dropped"
|
||||
);
|
||||
assert!(
|
||||
(narrow.text_left() - TEXT_LEFT).abs() < f32::EPSILON,
|
||||
"text origin unshifted when the gutter is dropped"
|
||||
);
|
||||
|
||||
let mut wide = State::new_headless(800, 200, text).expect("adapter was just available");
|
||||
wide.line_numbers = LineNumberMode::Absolute;
|
||||
assert!(
|
||||
wide.gutter_width_px() > 0.0,
|
||||
"a wide window keeps the gutter"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2769,6 +2769,28 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_all_modes_round_trip_through_postcard() {
|
||||
// UX gutter v14: `LineNumbers` swapped `enabled: bool` for a
|
||||
// `LineNumberMode` enum. Pin every variant's postcard shape so a
|
||||
// future enum reorder / addition can't silently change the wire.
|
||||
let bid = crate::buffer::BufferId::next();
|
||||
for mode in [
|
||||
LineNumberMode::Off,
|
||||
LineNumberMode::Absolute,
|
||||
LineNumberMode::Relative,
|
||||
LineNumberMode::Hybrid,
|
||||
] {
|
||||
let msg = InstanceMessage::LineNumbers {
|
||||
buffer_id: bid,
|
||||
mode,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_event_viewport_round_trips_through_postcard() {
|
||||
let ev = FrontendEvent::Viewport {
|
||||
|
|
|
|||
Loading…
Reference in New Issue