From 7fe8321ab010860f375dc3f5a63eb2157d3b04a2 Mon Sep 17 00:00:00 2001 From: vritser <16340478+vritser@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:07:29 +0800 Subject: [PATCH 1/2] fix(layout): remeasure chrome for position queries GNU pos_visible_p evaluates and measures mode/header/tab lines on every pos-visible/posn-at-point query, then walks the body under the measured text-area partition. Do the same for Position-scope queries instead of preserving the accepted chrome metrics: - add SynchronousQueryPosition, which selects chrome recomputation while window-end-style queries keep PreserveReservedMetrics; - let a measured-height mismatch drive the existing WindowChrome relayout, and reuse the measured metrics on that retry so each chrome format is evaluated once per query; - measure chrome when the retained partition leaves no body, so a shrinking row can restore body space; - bypass the geometry query cache for Position queries, whose cached observations carry the partition from their own point; - keep nested position queries made by a chrome :eval from becoming LayoutBusy errors, and fall back to accepted geometry instead. Refs the deferred P2 follow-up in #616 (src/xdisp.c:1732-1770). --- .../src/buffer_source/render_attempt.rs | 45 ++- .../src/buffer_source/render_plan.rs | 72 +++- .../src/buffer_source/window_render.rs | 32 +- crates/neomacs-layout-engine/src/engine.rs | 59 +++- .../src/engine/tests/engine_test.rs | 2 + .../tests/query_chrome_remeasure_test.rs | 320 ++++++++++++++++++ .../engine/tests/query_source_horizon_test.rs | 17 +- .../src/emacs_core/display/xdisp/mod.rs | 66 +++- .../display/xdisp/mode_line_output.rs | 25 ++ .../xdisp/tests/position_chrome_query_test.rs | 230 +++++++++++++ .../display/xdisp/tests/xdisp_test.rs | 2 + crates/neovm-core/src/window/display.rs | 11 + 12 files changed, 860 insertions(+), 21 deletions(-) create mode 100644 crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs create mode 100644 crates/neovm-core/src/emacs_core/display/xdisp/tests/position_chrome_query_test.rs diff --git a/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs b/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs index df5d41dd37..2af89ff59b 100644 --- a/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs +++ b/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs @@ -73,7 +73,15 @@ pub(crate) enum WindowPositionPublication { /// without entering redisplay_window. It publishes neither the temporary /// start/end nor a window-scroll-functions effect to the live minibuffer. InactiveEchoArea, + /// A GNU `window-end`/`vertical-motion`-style logical query. It walks from + /// the exact live start and preserves the accepted chrome partition. SynchronousQueryEnd, + /// A GNU `pos-visible-in-window-p` / `posn-at-point` query. Unlike + /// `window-end`, this path re-evaluates and measures the chrome formats + /// before laying out the body, exactly as `pos_visible_p` does. A measured + /// height that differs from the reserved one triggers the normal + /// `WindowChrome` relayout so the text-area bounds come from the new row. + SynchronousQueryPosition, } impl WindowPositionPublication { @@ -84,15 +92,27 @@ impl WindowPositionPublication { pub(crate) const fn uses_exact_window_start(self) -> bool { matches!( self, - Self::Snapshot | Self::InactiveEchoArea | Self::SynchronousQueryEnd + Self::Snapshot + | Self::InactiveEchoArea + | Self::SynchronousQueryEnd + | Self::SynchronousQueryPosition ) } - /// A synchronous GNU `window-end` query walks only the text area using - /// the chrome dimensions accepted by the last redisplay. It must not - /// evaluate mode/header/tab-line Lisp while answering the query. + /// Both query kinds walk the exact live start without publishing redisplay + /// positions. Only Position queries additionally remeasure chrome. pub(crate) const fn is_synchronous_query(self) -> bool { - matches!(self, Self::SynchronousQueryEnd) + matches!( + self, + Self::SynchronousQueryEnd | Self::SynchronousQueryPosition + ) + } + + /// Whether the query must evaluate and measure chrome before trusting the + /// text-area partition (GNU `pos_visible_p`, src/xdisp.c:1732-1770). + /// `window-end` deliberately keeps the accepted chrome metrics instead. + pub(crate) const fn remeasures_chrome(self) -> bool { + matches!(self, Self::SynchronousQueryPosition) } pub(crate) const fn keeps_complete_minibuffer_measurement_start(self) -> bool { @@ -133,7 +153,10 @@ impl WindowPositionPublication { window_start_lisp, ); } - Self::Snapshot | Self::InactiveEchoArea | Self::SynchronousQueryEnd => {} + Self::Snapshot + | Self::InactiveEchoArea + | Self::SynchronousQueryEnd + | Self::SynchronousQueryPosition => {} } None } @@ -153,6 +176,13 @@ pub(crate) struct BufferSourceRedisplayPublishRequest { #[derive(Debug)] pub(crate) enum BufferSourceRenderAttemptOutcome { Skipped, + /// A position query had no body under the retained partition. Measure + /// chrome anyway so a shrinking row can restore usable body space. + EmptyBodyChromeMeasured { + measured: crate::window_layout::WindowChromeMetrics, + freshness_before_chrome: neovm_core::window::WindowLayoutAttemptFreshness, + chrome_lisp_admission: crate::attempt_freshness::LayoutLispAdmission, + }, /// Lisp evaluated by a buffer-owned display source changed the window's /// source projection. The frame transaction must discard this attempt and /// recollect the live window/buffer pair. @@ -440,7 +470,8 @@ impl BufferSourceRedisplayPublishRequest { // discarded attempt. WindowPositionPublication::Snapshot | WindowPositionPublication::InactiveEchoArea - | WindowPositionPublication::SynchronousQueryEnd => {} + | WindowPositionPublication::SynchronousQueryEnd + | WindowPositionPublication::SynchronousQueryPosition => {} } } diff --git a/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs b/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs index 1ec7c58e1d..5c5d6b5305 100644 --- a/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs +++ b/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs @@ -170,6 +170,11 @@ pub(crate) struct BufferSourceOutputSetup { body_install_context: BufferSourceBodyInstallContext, retry_bounds: BufferSourceRetryBounds, position_publication: WindowPositionPublication, + /// A Position query evaluates chrome on its first attempt. Its retry runs + /// with the measured metrics already seeded into `WindowParams`, so chrome + /// must not be evaluated a second time (GNU `pos_visible_p` evaluates once + /// before walking the body). + remeasure_chrome: bool, } #[cfg(test)] @@ -591,6 +596,7 @@ impl BufferSourceOutputSetup { (text_y + text_height - walk_setup.window_top).round() as i64, ), position_publication: WindowPositionPublication::Redisplay, + remeasure_chrome: false, } } @@ -601,6 +607,57 @@ impl BufferSourceOutputSetup { self.position_publication = publication; self } + + pub(crate) fn with_remeasure_chrome(mut self, remeasure: bool) -> Self { + self.remeasure_chrome = remeasure; + self + } + + /// A retained chrome partition can consume the whole body after a resize. + /// Its current format must still get a chance to shrink before we skip it. + pub(crate) fn measure_chrome_for_empty_body( + self, + state: BufferSourceRenderAttemptContext<'_, '_, FS>, + request: WindowChromeRowsRenderRequest<'_, '_>, + params: &WindowParams, + ) -> BufferSourceRenderAttemptOutcome { + let (mut output, font_metrics, face_resolver, mut face_ids, _) = state.into_parts(); + if !self.remeasure_chrome + || !self.position_publication.remeasures_chrome() + || neovm_core::emacs_core::xdisp::mode_line_display_active(output.evaluator()) + { + return BufferSourceRenderAttemptOutcome::Skipped; + } + let freshness_before_chrome = match freshness_before_window_chrome( + output.evaluator(), + self.begin_request.frame_id(), + params, + ) { + Ok(freshness) => freshness, + Err(changed) => return changed.into(), + }; + let mut emitter = output.begin_text_window_output(self.begin_request); + let (output, evaluator) = output.into_parts(); + let services = + ChromeRowRenderServices::new(font_metrics, face_resolver.resolver(), &mut face_ids); + match render_or_retain_window_chrome( + output, + &mut emitter, + evaluator, + request, + services, + WindowChromeRowSource::Recompute, + ) { + Ok((measured, chrome_lisp_admission)) => { + BufferSourceRenderAttemptOutcome::EmptyBodyChromeMeasured { + measured, + freshness_before_chrome, + chrome_lisp_admission, + } + } + Err(changed) => changed.into(), + } + } } impl BufferSourceDefaultFacePlan { @@ -1852,7 +1909,20 @@ impl BufferSourceOutputSetup { // *measured* chrome heights so the window snapshot reports the real // (possibly taller, e.g. doom-modeline's bar) height rather than the // face-only estimate the text-area geometry was reserved from. - let chrome_source = if self.position_publication.is_synchronous_query() { + // A chrome `:eval` may itself ask for a position (GNU guards that + // re-entry, src/xdisp.c:1725-1731). Keep the accepted partition while + // a chrome formatter is already running instead of recursing. + let chrome_source = if self.remeasure_chrome + && self.position_publication.remeasures_chrome() + && !neovm_core::emacs_core::xdisp::mode_line_display_active(evaluator) + { + // GNU `pos_visible_p` re-evaluates and measures mode/header/tab + // lines on every call, before walking the body (src/xdisp.c:1732- + // 1770). Measure here too: a mismatch with the reserved metrics + // becomes `WindowLayoutOutcome::NeedsRelayout`, and the retry lays + // the body out under the measured text-area partition. + WindowChromeRowSource::Recompute + } else if self.position_publication.is_synchronous_query() { // GNU `Fwindow_end` builds a stack-local display iterator for the // text area. Reuse the already accepted partition instead of // evaluating status-line Lisp as a side effect of a geometry diff --git a/crates/neomacs-layout-engine/src/buffer_source/window_render.rs b/crates/neomacs-layout-engine/src/buffer_source/window_render.rs index 93d85fbc1f..e4817969e2 100644 --- a/crates/neomacs-layout-engine/src/buffer_source/window_render.rs +++ b/crates/neomacs-layout-engine/src/buffer_source/window_render.rs @@ -132,6 +132,9 @@ where buffer_name: &'a str, reserve_right_border_col: bool, position_publication: WindowPositionPublication, + /// Evaluate and measure chrome on this attempt. Set for a Position query's + /// first pass; its measured-metric retry clears it. + remeasure_chrome: bool, resolved_window_start: ResolvedWindowStart, forward_viewport_measurement: Option, } @@ -164,6 +167,7 @@ where buffer_name, reserve_right_border_col, position_publication: WindowPositionPublication::Redisplay, + remeasure_chrome: false, resolved_window_start, forward_viewport_measurement: None, } @@ -177,6 +181,11 @@ where self } + pub(crate) fn with_remeasure_chrome(mut self, remeasure: bool) -> Self { + self.remeasure_chrome = remeasure; + self + } + pub(crate) fn with_forward_viewport_measurement( mut self, measurement: Option, @@ -249,6 +258,7 @@ where buffer_name: self.buffer_name, reserve_right_border_col: self.reserve_right_border_col, position_publication: self.position_publication, + remeasure_chrome: self.remeasure_chrome, resolved_window_start: self.resolved_window_start, forward_viewport_measurement: self.forward_viewport_measurement.clone(), }; @@ -307,6 +317,7 @@ where buffer_name: self.buffer_name, reserve_right_border_col: self.reserve_right_border_col, position_publication: self.position_publication, + remeasure_chrome: self.remeasure_chrome, resolved_window_start: self.resolved_window_start, forward_viewport_measurement: self.forward_viewport_measurement.clone(), }; @@ -411,6 +422,7 @@ where buffer_name, reserve_right_border_col, position_publication, + remeasure_chrome, resolved_window_start, forward_viewport_measurement, } = self; @@ -587,7 +599,7 @@ where bytes_read ); - if geometry.text_height <= 0.0 || geometry.text_width <= 0.0 { + if geometry.text_width <= 0.0 { return BufferSourceRenderAttemptOutcome::Skipped; } @@ -630,7 +642,23 @@ where geometry.max_rows, &walk_setup, ) - .with_position_publication(position_publication); + .with_position_publication(position_publication) + .with_remeasure_chrome(remeasure_chrome); + + if geometry.text_height <= 0.0 { + return output_setup.measure_chrome_for_empty_body( + state, + chrome_plan.render_request( + params, + *layout_box, + geometry.mode_line_display_row, + reserve_right_border_col, + window_metrics, + buffer_name, + ), + params, + ); + } output_setup.render_body_attempt( &mut walk_setup, diff --git a/crates/neomacs-layout-engine/src/engine.rs b/crates/neomacs-layout-engine/src/engine.rs index 29029782aa..d82b94a3bc 100644 --- a/crates/neomacs-layout-engine/src/engine.rs +++ b/crates/neomacs-layout-engine/src/engine.rs @@ -392,7 +392,15 @@ fn window_position_publication( purpose, WindowLayoutWalkPurpose::SynchronousQuery(_) | WindowLayoutWalkPurpose::MiniPreparation ) { - return WindowPositionPublication::SynchronousQueryEnd; + // `pos-visible-in-window-p`/`posn-at-point` answer from freshly measured + // chrome (GNU `pos_visible_p`, src/xdisp.c:1732-1770); the other logical + // queries preserve the accepted partition. + return match purpose { + WindowLayoutWalkPurpose::SynchronousQuery( + neovm_core::window::WindowLayoutQueryScope::Position { .. }, + ) => WindowPositionPublication::SynchronousQueryPosition, + _ => WindowPositionPublication::SynchronousQueryEnd, + }; } if source == WindowDisplaySource::InactiveEchoArea { return WindowPositionPublication::InactiveEchoArea; @@ -2041,6 +2049,11 @@ impl LayoutEngine { && !(evaluator.gnu_redisplay_hooks_policy_enabled() && evaluator.gnu_redisplay_transaction_active()); + // A Position query measures chrome on its first physical attempt; the + // WindowChrome relayout immediately below must reuse those measured + // metrics instead of evaluating the formats a second time. + let mut chrome_remeasured_next_round: std::collections::HashSet = + std::collections::HashSet::new(); let ( frame_params, curr_window_infos, @@ -2050,6 +2063,7 @@ impl LayoutEngine { accepted_window_navigation_intents, accepted_frame_navigation_intent, ) = 'frame_layout: loop { + let chrome_remeasured = std::mem::take(&mut chrome_remeasured_next_round); // Re-entering this loop means the preceding physical frame walk // was rejected. Restore every leaf publication before collecting // the next attempt's canonical previous-viewport evidence. @@ -2848,6 +2862,8 @@ impl LayoutEngine { scroll_replay.take(), is_edit, position_publication, + !chrome_remeasured + .contains(&DisplayWindowId::new(live_inputs.window.window_id)), &mut lisp_ledger, attempt_topology_generation, &face_attempt, @@ -3064,6 +3080,7 @@ impl LayoutEngine { } window_chrome_metrics .insert(DisplayWindowId::new(params.window_id), measured); + chrome_remeasured_next_round.insert(DisplayWindowId::new(params.window_id)); continue 'frame_layout; } }; @@ -4467,7 +4484,17 @@ impl LayoutEngine { // An unproved selected Rows request must run the canonical walk; // neither a legacy observation nor a prepared viewport can answer. } else { + // Position queries answer through GNU `pos_visible_p`: chrome is + // re-evaluated and measured, and line-number row faces may depend + // on point. A cached observation carries the text-area partition + // from its own point, so re-placing its rows would reproduce stale + // chrome bounds. Run the canonical walk instead. + let position_query = matches!( + scope, + neovm_core::window::WindowLayoutQueryScope::Position { .. } + ); if !text_extent + && !position_query && let Some(query) = self.query_cache.get(evaluator, frame_id, window_id, scope) { return Ok(query); @@ -5306,6 +5333,9 @@ impl LayoutEngine { scroll_replay: Option, is_edit: bool, position_publication: WindowPositionPublication, + // Whether this attempt still needs to evaluate/measure chrome. The + // retry after `WindowChrome` uses the already measured metrics. + remeasure_chrome: bool, lisp_ledger: &mut RedisplayLispLedger, topology_generation: u64, face_attempt: &FrameFaceAttempt, @@ -5630,6 +5660,7 @@ impl LayoutEngine { resolved_window_start, ) .with_position_publication(site_publication) + .with_remeasure_chrome(remeasure_chrome) .with_forward_viewport_measurement(match &viewport_resolution { ViewportResolutionPhase::Measure(measurement) => Some(measurement.clone()), ViewportResolutionPhase::Resolve | ViewportResolutionPhase::Commit(_) => None, @@ -5659,6 +5690,11 @@ impl LayoutEngine { freshness_before_chrome, chrome_lisp_admission, .. + } + | BufferSourceRenderAttemptOutcome::EmptyBodyChromeMeasured { + freshness_before_chrome, + chrome_lisp_admission, + .. } => crate::attempt_freshness::after_span( evaluator, freshness_before_chrome, @@ -5682,6 +5718,10 @@ impl LayoutEngine { BufferSourceRenderAttemptOutcome::Finished { freshness_before_chrome, .. + } + | BufferSourceRenderAttemptOutcome::EmptyBodyChromeMeasured { + freshness_before_chrome, + .. }, Some(freshness_after_leaf), ) => { @@ -5877,7 +5917,22 @@ impl LayoutEngine { viewport_resolution: ViewportResolutionPhase::Commit(site.window_start()), })); } - BufferSourceRenderAttemptOutcome::Skipped => { + BufferSourceRenderAttemptOutcome::EmptyBodyChromeMeasured { measured, .. } + if layout_box.chrome() != measured => + { + if let Some(attempt) = window_end_attempt.take() { + evaluator.reject_redisplay_window_end_attempt(attempt); + } + return LeafLayoutAttempt::Completed { + outcome: WindowLayoutOutcome::NeedsRelayout { + assumed: layout_box.chrome(), + measured, + }, + window_end_attempt: None, + }; + } + BufferSourceRenderAttemptOutcome::Skipped + | BufferSourceRenderAttemptOutcome::EmptyBodyChromeMeasured { .. } => { self.frame_output .render_window_info(WindowFrameInfoRenderRequest::new( params, diff --git a/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs b/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs index fa8885aa7b..069301d216 100644 --- a/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs +++ b/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs @@ -114,6 +114,8 @@ mod posn_frame_pool_engine_test; #[cfg(test)] #[path = "posn_frame_pool_guard_test.rs"] mod posn_frame_pool_guard_test; +#[path = "query_chrome_remeasure_test.rs"] +mod query_chrome_remeasure_test; #[path = "query_source_horizon_test.rs"] mod query_source_horizon_test; #[path = "replay_cursor_on_tab_test.rs"] diff --git a/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs b/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs new file mode 100644 index 0000000000..001b322548 --- /dev/null +++ b/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs @@ -0,0 +1,320 @@ +//! Position queries must remeasure point-dependent chrome before answering. +//! +//! GNU `pos_visible_p` evaluates and measures mode/header/tab lines on every +//! call, then walks the body under those measured text-area bounds +//! (src/xdisp.c:1732-1770). Logical `window-end` queries keep the accepted +//! partition instead. These tests exercise the production query path, not a +//! mocked `WindowLayoutQuery`. + +use super::*; +use neovm_core::buffer::{EmacsBytePos, LispCharPos1}; +use neovm_core::window::{FrameId, Window, WindowId, WindowLayoutQueryScope}; + +fn move_point(eval: &mut Context, frame: FrameId, buffer: BufferId, window: WindowId, pos: i64) { + eval.buffer_manager_mut() + .get_mut(buffer) + .expect("buffer") + .goto_emacs_byte_pos(EmacsBytePos::new((pos - 1) as usize)); + match eval + .frame_manager_mut() + .get_mut(frame) + .expect("frame") + .find_window_mut(window) + .expect("window") + { + Window::Leaf { point, .. } => *point = LispCharPos1::new(pos), + other => panic!("expected leaf window, got {other:?}"), + } +} + +fn query( + eval: &mut Context, + engine: &mut LayoutEngine, + frame: FrameId, + window: WindowId, + target: i64, +) -> WindowDisplaySnapshot { + engine + .query_window_layout( + eval, + frame, + window, + WindowLayoutQueryScope::Position { + target: LispCharPos1::new(target), + }, + ) + .expect("position query converges") + .into_geometry() + .expect("query geometry") +} + +#[test] +fn position_query_remeasures_point_dependent_header_height() { + let text = "line\n".repeat(80); + let (mut eval, frame, buffer, window) = incr_editing_frame(&text, 800, 600); + eval.frame_manager_mut() + .get_mut(frame) + .expect("frame") + .set_window_system(Some(neovm_core::emacs_core::Value::symbol("neo"))); + eval.eval_str( + "(progn + (setq mode-line-format nil + tab-line-format nil + header-line-format + '(:eval (propertize \"header\" 'face + (list :height (if (= (point) 1) 1.0 4.0))))) + (goto-char 1))", + ) + .expect("header format"); + + let mut engine = LayoutEngine::new(); + let before = engine + .query_window_layout(&mut eval, frame, window, WindowLayoutQueryScope::Viewport) + .expect("viewport query converges") + .into_geometry() + .expect("query geometry"); + assert_eq!( + before.header_line_height, + before.regions.header_line.expect("header region").height as i64, + "the short header must be one consistent row" + ); + let short_header = before.header_line_height; + assert!(short_header > 0, "header line must be present"); + let short_body_y = before.regions.text_body.y; + let bottom = before + .rows + .iter() + .rev() + .find_map(|row| row.start_buffer_pos.filter(|pos| *pos > LispCharPos1::ONE)) + .expect("bottom source row"); + + // Move point without redisplay. The header :eval now asks for 4x height. + move_point(&mut eval, frame, buffer, window, 5); + + // `window-end` walks with the accepted partition; it must not evaluate the + // chrome Lisp just because the caller asks for a query. + let end_query = engine + .query_window_layout(&mut eval, frame, window, WindowLayoutQueryScope::Viewport) + .expect("window-end query converges") + .into_geometry() + .expect("query geometry"); + assert!( + end_query.header_line_height < short_header * 2, + "window-end must not evaluate point-dependent chrome \ + (accepted={short_header}, query={})", + end_query.header_line_height + ); + + // `pos-visible-in-window-p`/`posn-at-point` must measure the new header and + // relayout the body under it before answering. GNU evaluates the chrome + // format once per query; the measured-metric retry must not re-evaluate it. + crate::display_status_line::reset_mode_line_eval_count(); + let after = query(&mut eval, &mut engine, frame, window, 5); + assert_eq!( + crate::display_status_line::header_line_eval_count(), + 1, + "a Position query must evaluate the header format exactly once" + ); + assert!( + after.header_line_height >= short_header * 3, + "position query must report the freshly measured header height \ + (before={short_header}, after={})", + after.header_line_height + ); + assert_eq!( + after.regions.header_line.expect("header region").height as i64, + after.header_line_height, + "the published region and metric must describe the same row" + ); + assert!( + after.regions.text_body.y > short_body_y, + "the text area must move down by the measured header growth" + ); + assert_eq!( + after.regions.text_body.y as i64, + after.header_line_height + after.tab_line_height, + "text-area origin is tab + header height" + ); + + // The bottom-row regression: a source row that was visible under the short + // header is outside the (shorter) body once the point-dependent header + // grows, exactly as GNU `pos_visible_p` reports. + let hidden = query(&mut eval, &mut engine, frame, window, bottom.as_i64()); + assert!( + hidden.point_for_buffer_pos(bottom).is_none(), + "the former bottom row must no longer be visible under the grown header" + ); +} + +#[test] +fn position_query_remeasures_point_dependent_mode_line_height() { + let text = "line\n".repeat(80); + let (mut eval, frame, buffer, window) = incr_editing_frame(&text, 800, 600); + eval.frame_manager_mut() + .get_mut(frame) + .expect("frame") + .set_window_system(Some(neovm_core::emacs_core::Value::symbol("neo"))); + eval.eval_str( + "(progn + (setq header-line-format nil + tab-line-format nil + mode-line-format + '(:eval (propertize \"mode\" 'face + (list :height (if (= (point) 1) 1.0 4.0))))) + (goto-char 1))", + ) + .expect("mode-line format"); + + let mut engine = LayoutEngine::new(); + let before = engine + .query_window_layout(&mut eval, frame, window, WindowLayoutQueryScope::Viewport) + .expect("viewport query converges") + .into_geometry() + .expect("query geometry"); + let short_mode = before.mode_line_height; + assert!(short_mode > 0, "mode line must be present"); + let before_body_height = before.regions.text_body.height; + let bottom = before + .rows + .iter() + .rev() + .find_map(|row| row.start_buffer_pos.filter(|pos| *pos > LispCharPos1::ONE)) + .expect("bottom source row"); + + move_point(&mut eval, frame, buffer, window, 5); + let after = query(&mut eval, &mut engine, frame, window, 5); + assert!( + after.mode_line_height >= short_mode * 3, + "position query must report the freshly measured mode-line height \ + (before={short_mode}, after={})", + after.mode_line_height + ); + assert!( + after.regions.text_body.height < before_body_height, + "a taller mode line must shrink the body without moving its origin" + ); + assert_eq!( + after.regions.text_body.y, before.regions.text_body.y, + "the mode line is below the body; the origin does not move" + ); + + let hidden = query(&mut eval, &mut engine, frame, window, bottom.as_i64()); + assert!( + hidden.point_for_buffer_pos(bottom).is_none(), + "the former bottom row must no longer be visible under the grown mode line" + ); +} + +#[test] +fn position_query_remeasures_chrome_when_retained_height_leaves_no_body() { + let (mut eval, frame, buffer, window) = incr_editing_frame("line\nline\n", 800, 600); + eval.frame_manager_mut() + .get_mut(frame) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + eval.eval_str( + "(setq mode-line-format nil tab-line-format nil + header-line-format '(:eval (propertize \"header\" 'face + (list :height (if (= (point) 1) 4.0 1.0)))))", + ) + .unwrap(); + move_point(&mut eval, frame, buffer, window, 1); + let mut engine = LayoutEngine::new(); + let tall = query(&mut eval, &mut engine, frame, window, 1); + let retained = crate::window_layout::WindowChromeMetrics::from_snapshot(&tall); + // Reproduce the accepted partition carried by a presentation, then resize + // the leaf below that height while leaving room for the shorter header. + engine.retained_window_chrome_metrics.insert( + neomacs_display_protocol::types::DisplayWindowId::new(window.0 as i64), + retained, + ); + let leaf = eval + .frame_manager_mut() + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap(); + let mut bounds = *leaf.bounds(); + bounds.height = retained.header_line_height; + leaf.set_bounds(bounds); + let empty = engine + .query_window_layout(&mut eval, frame, window, WindowLayoutQueryScope::Viewport) + .unwrap() + .into_geometry() + .unwrap(); + assert_eq!(empty.regions.text_body.height, 0.0); + + move_point(&mut eval, frame, buffer, window, 5); + crate::display_status_line::reset_mode_line_eval_count(); + let recovered = query(&mut eval, &mut engine, frame, window, 1); + assert_eq!(crate::display_status_line::header_line_eval_count(), 1); + assert!(recovered.header_line_height < tall.header_line_height); + assert!(recovered.regions.text_body.height > 0.0); + assert!(recovered.point_for_buffer_pos(LispCharPos1::ONE).is_some()); +} + +#[test] +fn chrome_position_queries_do_not_reenter_chrome_or_signal_layout_busy() { + use neovm_core::window::WindowLayoutQueryOutcome; + + // Exercise both entry points: a formatter calling a free query adapter, + // and a query's formatter calling the already-running adapter. + for outer_is_query in [false, true] { + let (mut eval, frame, buffer, window) = incr_editing_frame("line\nline\n", 800, 600); + eval.frame_manager_mut().get_mut(frame).unwrap().initial = false; + eval.eval_str( + "(setq mode-line-format nil tab-line-format nil + chrome-query-returned nil + header-line-format '(:eval (progn + (posn-at-point 1) + (setq chrome-query-returned t) + \"header\")))", + ) + .unwrap(); + move_point(&mut eval, frame, buffer, window, 1); + let mut engine = WindowLayoutQueryEngine::new(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let observed = calls.clone(); + eval.install_window_layout_query(move |eval, frame, window, scope| { + observed.set(observed.get() + 1); + match engine.query_window_layout(eval, frame, window, scope) { + Ok(query) => WindowLayoutQueryOutcome::Ready(query), + Err(error) => WindowLayoutQueryOutcome::Failed(error), + } + }); + crate::display_status_line::reset_mode_line_eval_count(); + if outer_is_query { + assert!(!eval.eval_str("(posn-at-point 1)").unwrap().is_nil()); + assert_eq!(crate::display_status_line::header_line_eval_count(), 1); + } else { + let format = eval + .buffers + .get(buffer) + .expect("buffer") + .buffer_local_value("header-line-format") + .expect("header format"); + let rendered = + neovm_core::emacs_core::xdisp::try_format_mode_line_for_display_with_sources( + &mut eval, + format, + Value::make_window(window.0), + Value::make_buffer(buffer), + 80, + ) + .unwrap(); + assert_eq!( + rendered.value().as_runtime_string_owned().as_deref(), + Some("header") + ); + assert_eq!(crate::display_status_line::header_line_eval_count(), 0); + } + assert_eq!(calls.get(), 1); + assert_eq!( + eval.variable_value_by_name("chrome-query-returned"), + Some(Value::T) + ); + assert!(!neovm_core::emacs_core::xdisp::mode_line_display_active( + &eval + )); + } +} diff --git a/crates/neomacs-layout-engine/src/engine/tests/query_source_horizon_test.rs b/crates/neomacs-layout-engine/src/engine/tests/query_source_horizon_test.rs index 83a3c50cae..aa3123c611 100644 --- a/crates/neomacs-layout-engine/src/engine/tests/query_source_horizon_test.rs +++ b/crates/neomacs-layout-engine/src/engine/tests/query_source_horizon_test.rs @@ -84,13 +84,18 @@ fn truncated_position_target_stops_before_long_physical_tail() { .point_for_buffer_pos(LispCharPos1::new(tail as i64 + 2)) .is_none() ); + // A Position query now measures chrome, so the snapshot's rows + // can include a chrome row after the last source row. Bound the + // final SOURCE row, which is what this acquisition asserts. assert!( geometry .rows - .last() - .unwrap() + .iter() + .rev() + .find(|row| row.start_buffer_pos.is_some()) + .expect("last source row") .end_buffer_pos - .unwrap() + .expect("complete source row") .as_i64() <= target + 1 ); @@ -218,11 +223,15 @@ fn truncated_target_prefix_does_not_include_later_tall_face_or_reuse_its_coverag .into_geometry() .unwrap(); assert_eq!(engine.query_row_coverage, QueryRowCoverage::TargetPrefix); + // Position queries never consult the cache (they must re-evaluate and + // measure chrome, GNU `pos_visible_p`), and a measured snapshot carries + // chrome strings that are not cache roots. The canonical walk still has to + // produce the early prefix on every call. assert!( engine .query_cache .get(&eval, frame, window, early_scope) - .is_some() + .is_none() ); let later = engine .query_window_layout(&mut eval, frame, window, later_scope) diff --git a/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs b/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs index 219d58ae5b..409f33fac0 100644 --- a/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs @@ -77,7 +77,8 @@ use crate::buffer::{ use crate::emacs_core::error::LispCondition; use crate::emacs_core::error::{expect_args, expect_args_range}; use crate::window::{ - DisplayRowSnapshot, FrameId, FrameManager, Window, WindowDisplaySnapshot, WindowId, + DisplayRowSnapshot, FrameId, FrameManager, Window, WindowChromeLine, WindowChromePresence, + WindowDisplaySnapshot, WindowId, }; use std::num::NonZeroUsize; use std::ops::ControlFlow; @@ -2224,6 +2225,7 @@ pub fn try_format_mode_line_for_display_with_sources( buffer: Value, target_cols: usize, ) -> Result { + let _display_scope = eval.mode_line_output_owner().enter_display(); try_format_mode_line_display( eval, format_val, @@ -2235,6 +2237,12 @@ pub fn try_format_mode_line_for_display_with_sources( ) } +/// Whether a mode/header/tab-line formatter is currently executing Lisp. +/// This is independent of a deferred nonlocal exit from that formatter. +pub fn mode_line_display_active(eval: &super::eval::Context) -> bool { + eval.mode_line_output_owner().display_active() +} + /// Frame-title evaluation uses the same safe evaluator, but unlike actual /// mode/header/tab lines GNU does not save match data around the title walker. pub fn try_format_frame_title_for_display( @@ -8636,10 +8644,12 @@ fn retained_rows_answer_for_live_window( { return Ok(true); } - // Stable format definitions do not prove stable geometry after point - // moves: the current line-number face can change row metrics even with - // absolute numbering. - if !line_numbers_are_point_independent(eval, fid, buffer_id) { + // Stable format/face definitions do not prove stable geometry after point + // moves: chrome can evaluate Lisp, and the current line-number face can + // change row metrics even with absolute numbering. + if !line_numbers_are_point_independent(eval, fid, buffer_id) + || !chrome_is_point_independent(eval, fid, wid, buffer_id) + { return Ok(false); } let Some(current) = eval.window_display_snapshot_freshness(fid, wid, buffer_id) else { @@ -8713,6 +8723,52 @@ fn line_number_faces_are_remapped(eval: &super::eval::Context, buffer: &Buffer) remapping.get("line-number").is_some() || remapping.get("line-number-current-line").is_some() } +/// Whether the chrome partition is proven not to change when point moves. +/// +/// GNU `pos_visible_p` re-evaluates all three chrome formats and measures +/// their rendered rows on *every* query (src/xdisp.c:1732-1770, 28216-28399), +/// so a `:eval` that reads point is answered from fresh geometry. This port +/// answers from retained rows whose measured chrome heights came from an +/// earlier point. A position query remeasures chrome and retries the body if +/// its partition changes, so point-only reuse requires that no chrome line is +/// present. Presence, not the format value, is the +/// question GNU asks here (`window_wants_mode_line` and friends, +/// src/window.c:6037-6101): a format on a window too short to show it, or on +/// the minibuffer, cannot affect the text area. +/// +/// Any present chrome requires the canonical query and its chrome measurement. +/// That is deliberately stronger than scanning +/// the format for `:eval`: chrome percentages and `%M`/`%m` splice arbitrary +/// propertized strings, and a static proof of row height would have to model +/// every display spec and face metric those strings can introduce. +fn chrome_is_point_independent( + eval: &super::eval::Context, + frame_id: FrameId, + window_id: WindowId, + buffer_id: crate::buffer::BufferId, +) -> bool { + let (Some(frame), Some(buffer)) = (eval.frames.get(frame_id), eval.buffers.get(buffer_id)) + else { + return false; + }; + let Some(window) = frame.find_window(window_id) else { + return false; + }; + let presence = WindowChromePresence::resolve( + window, + buffer, + frame, + eval.frames.is_minibuffer_window_id(window_id), + ); + ![ + WindowChromeLine::ModeLine, + WindowChromeLine::HeaderLine, + WindowChromeLine::TabLine, + ] + .into_iter() + .any(|line| presence.contains(line)) +} + fn resolve_exact_visible_metrics( frames: &crate::window::FrameManager, buffers: &crate::buffer::BufferManager, diff --git a/crates/neovm-core/src/emacs_core/display/xdisp/mode_line_output.rs b/crates/neovm-core/src/emacs_core/display/xdisp/mode_line_output.rs index ae9f5fb5b3..d515e99526 100644 --- a/crates/neovm-core/src/emacs_core/display/xdisp/mode_line_output.rs +++ b/crates/neovm-core/src/emacs_core/display/xdisp/mode_line_output.rs @@ -43,6 +43,7 @@ enum OutputKind { struct OutputState { element: Cell, target: Cell, + display_active: Cell, } /// Native state of one evaluator World; no Lisp values or retained output. @@ -55,6 +56,17 @@ static_assertions::assert_not_impl_any!(ModeLineOutputOwner: Send, Sync); static_assertions::assert_eq_size!(ModeLineOutputOwner, usize); impl ModeLineOutputOwner { + pub(super) fn display_active(&self) -> bool { + self.0.display_active.get() + } + + pub(super) fn enter_display(&self) -> ModeLineDisplayScope { + ModeLineDisplayScope { + owner: self.clone(), + previous: self.0.display_active.replace(true), + } + } + fn advance(&self) -> Result { let next = self.0.element.get().next()?; self.0.element.set(next); @@ -75,6 +87,19 @@ impl ModeLineOutputOwner { } } +/// Keep the active display extent across Lisp callbacks without borrowing Context. +/// Drop restores the enclosing extent on both normal return and unwinding. +pub(super) struct ModeLineDisplayScope { + owner: ModeLineOutputOwner, + previous: bool, +} + +impl Drop for ModeLineDisplayScope { + fn drop(&mut self) { + self.owner.0.display_active.set(self.previous); + } +} + /// STRING authority borrowed from a live admitted formatter extent. /// The scope owns the shared state across callbacks; this handle cannot outlive /// that scope and copies the live authority rather than an ordinal snapshot. diff --git a/crates/neovm-core/src/emacs_core/display/xdisp/tests/position_chrome_query_test.rs b/crates/neovm-core/src/emacs_core/display/xdisp/tests/position_chrome_query_test.rs new file mode 100644 index 0000000000..22b9405aa1 --- /dev/null +++ b/crates/neovm-core/src/emacs_core/display/xdisp/tests/position_chrome_query_test.rs @@ -0,0 +1,230 @@ +//! Chrome-presence gating for point-only retained-row reuse. +//! +//! GNU `pos_visible_p` measures chrome on every query, so retained rows may +//! answer a point-only move only when no chrome line can change the text area. + +use super::*; +use crate::buffer::LispCharPos1; +use crate::emacs_core::value::Value; +use crate::window::{Window, WindowLayoutQuery, WindowLayoutQueryOutcome, WindowLayoutQueryScope}; + +fn assert_point_only_warm_query_reuse(setup: &str, reuse_after_move: bool) { + use crate::window::{WindowLayoutQuery, WindowLayoutQueryOutcome, WindowLayoutQueryScope}; + + let mut eval = interactive_context(); + let buffer = eval.buffers.current_buffer().unwrap().id; + eval.buffers.get_mut(buffer).unwrap().insert("a\nb\n"); + let frame = eval + .frames + .create_frame("point-only-row-query", 160, 96, buffer); + let window = eval.frames.get(frame).unwrap().selected_window; + eval.frames.get_mut(frame).unwrap().initial = false; + eval.set_variable("point-query-window", Value::make_window(window.0)); + eval.eval_str( + "(progn + (set-default 'display-line-numbers nil) + (set-default 'mode-line-format nil) + (set-default 'header-line-format nil) + (set-default 'tab-line-format nil) + (goto-char 1))", + ) + .unwrap(); + eval.eval_str(setup).unwrap(); + match eval + .frames + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + crate::window::Window::Leaf { point, .. } => *point = LispCharPos1::ONE, + other => panic!("expected leaf window, got {other:?}"), + } + + // Deliberately give the current buffer the opposite admission conditions. + // The gate must inspect the queried window's buffer, not this buffer. + let other_buffer = eval.buffers.create_buffer("*point-query-other*"); + eval.set_current_buffer_unrecorded(other_buffer).unwrap(); + let other_value = if reuse_after_move { + Value::T + } else { + Value::NIL + }; + for name in [ + "display-line-numbers", + "mode-line-format", + "header-line-format", + "tab-line-format", + ] { + eval.buffers + .set_buffer_local_property(other_buffer, name, other_value) + .unwrap(); + } + + let freshness = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + let mut warm = crate::window::WindowDisplaySnapshot { + window_id: window, + regions_materialized: true, + points: vec![crate::window::DisplayPointSnapshot { + role: crate::window::DisplayPointRole::Glyph, + buffer_pos: LispCharPos1::new(3), + x: 0, + y: 16, + width: 8, + height: 16, + row: 1, + col: 0, + }], + rows: vec![crate::window::DisplayRowSnapshot { + row: 1, + y: 16, + height: 16, + start_buffer_pos: Some(LispCharPos1::new(3)), + end_buffer_pos: Some(LispCharPos1::new(4)), + ..Default::default() + }], + buffer_modiff: Some(eval.buffers.get(buffer).unwrap().modified_tick()), + layout_freshness: Some(freshness.clone()), + ..Default::default() + }; + warm.regions.text_body.width = 160.0; + warm.regions.text_body.height = 32.0; + eval.frames + .get_mut(frame) + .unwrap() + .commit_redisplay_cache_for_test(vec![warm]); + + // Mock canonical bounds, not a font/chrome metric engine: the old body + // admitted the target at y=16 in [0,32); the new visible body is [0,16), + // with no target point. Header growth or current-line number face metrics + // can invalidate that old admission without any display-variable mutation. + let mut refreshed = crate::window::WindowDisplaySnapshot { + window_id: window, + regions_materialized: true, + rows: vec![crate::window::DisplayRowSnapshot { + row: 0, + y: 0, + height: 16, + start_buffer_pos: Some(LispCharPos1::ONE), + end_buffer_pos: Some(LispCharPos1::new(2)), + ..Default::default() + }], + ..Default::default() + }; + refreshed.regions.text_body.width = 160.0; + refreshed.regions.text_body.height = 16.0; + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let observed = calls.clone(); + eval.install_window_layout_query(move |_, fid, wid, scope| { + assert_eq!((fid, wid), (frame, window)); + assert_eq!( + scope, + WindowLayoutQueryScope::Position { + target: LispCharPos1::new(3) + } + ); + observed.set(observed.get() + 1); + WindowLayoutQueryOutcome::Ready(WindowLayoutQuery::new( + LispCharPos1::ONE, + Some(refreshed.clone()), + )) + }); + let query = |eval: &mut Context| { + builtin_posn_at_point(eval, vec![Value::fixnum(3), Value::make_window(window.0)]).unwrap() + }; + assert!( + !query(&mut eval).is_nil(), + "exact-fresh rows must answer: {setup}" + ); + assert_eq!(calls.get(), 0, "exact-fresh rows must not query: {setup}"); + + eval.buffers + .get_mut(buffer) + .unwrap() + .goto_emacs_byte_pos(crate::buffer::EmacsBytePos::new(3)); + match eval + .frames + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + crate::window::Window::Leaf { point, .. } => { + assert_eq!(*point, LispCharPos1::ONE); + *point = LispCharPos1::new(3); + } + other => panic!("expected leaf window, got {other:?}"), + } + let moved = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + assert_ne!( + freshness, moved, + "saved window point must change freshness: {setup}" + ); + assert!( + freshness.same_position_rows_ignoring_point(&moved), + "only point may change: {setup}" + ); + assert!( + eval.fresh_window_display_snapshot(frame, window, buffer) + .is_none() + ); + assert_eq!(query(&mut eval).is_nil(), !reuse_after_move, "{setup}"); + assert_eq!(calls.get(), usize::from(!reuse_after_move), "{setup}"); + assert_eq!(eval.buffers.current_buffer_id(), Some(other_buffer)); +} + +#[test] +fn posn_at_point_requeries_warm_rows_for_chrome_after_point_moves() { + for name in ["mode-line-format", "header-line-format", "tab-line-format"] { + for setup in [ + format!("(set-default '{name} \"static chrome\")"), + format!("(setq {name} \"local chrome\")"), + format!("(set-window-parameter point-query-window '{name} \"window chrome\")"), + format!( + "(progn (set-default '{name} \"default chrome\") (set-window-parameter point-query-window '{name} nil))" + ), + ] { + assert_point_only_warm_query_reuse(&setup, false); + } + } + for setup in [ + "(setq header-line-format '(:eval (propertize \"header\" 'face (list :height (if (= (point) 1) 1.0 4.0)))))", + "(set-window-parameter point-query-window 'header-line-format '(:eval (propertize \"header\" 'face (list :height (if (= (point) 1) 1.0 4.0)))))", + ] { + assert_point_only_warm_query_reuse(setup, false); + } +} + +#[test] +fn posn_at_point_reuses_point_only_warm_rows_with_effective_nil_chrome_and_numbers() { + assert_point_only_warm_query_reuse("nil", true); + for name in [ + "display-line-numbers", + "mode-line-format", + "header-line-format", + "tab-line-format", + ] { + assert_point_only_warm_query_reuse( + &format!("(progn (set-default '{name} t) (set (make-local-variable '{name}) nil))"), + true, + ); + } + assert_point_only_warm_query_reuse( + "(set-window-parameter point-query-window 'header-line-format nil)", + true, + ); + // GNU `window_wants_header_line` treats a `none` parameter as absence + // (src/window.c:6067-6081); the buffer format behind it is irrelevant. + for name in ["mode-line-format", "header-line-format", "tab-line-format"] { + assert_point_only_warm_query_reuse( + &format!( + "(progn (set-default '{name} \"buffer chrome\") (set-window-parameter point-query-window '{name} 'none))" + ), + true, + ); + } +} diff --git a/crates/neovm-core/src/emacs_core/display/xdisp/tests/xdisp_test.rs b/crates/neovm-core/src/emacs_core/display/xdisp/tests/xdisp_test.rs index b856e5d5c5..0d1d76aa63 100644 --- a/crates/neovm-core/src/emacs_core/display/xdisp/tests/xdisp_test.rs +++ b/crates/neovm-core/src/emacs_core/display/xdisp/tests/xdisp_test.rs @@ -26,6 +26,8 @@ mod approx_window_text_test; #[cfg(test)] #[path = "mode_line_prop_borrow_test.rs"] mod mode_line_prop_borrow_test; +#[path = "position_chrome_query_test.rs"] +mod position_chrome_query_test; #[cfg(test)] #[path = "mode_line_plain_field_test.rs"] diff --git a/crates/neovm-core/src/window/display.rs b/crates/neovm-core/src/window/display.rs index b3385747a4..75fdb8ce68 100644 --- a/crates/neovm-core/src/window/display.rs +++ b/crates/neovm-core/src/window/display.rs @@ -759,6 +759,17 @@ impl crate::emacs_core::eval::Context { return crate::window::WindowLayoutQueryOutcome::Unavailable; } WindowLayoutQueryAdapter::Running => { + // A position query can measure chrome whose :eval asks for + // another position. The outer adapter owns the walk; let the + // nested consumer fall back to accepted geometry instead of + // turning an ordinary chrome expression into a layout error. + if matches!( + scope, + crate::window::WindowLayoutQueryScope::Position { .. } + ) && crate::emacs_core::xdisp::mode_line_display_active(self) + { + return crate::window::WindowLayoutQueryOutcome::Unavailable; + } return crate::window::WindowLayoutQueryOutcome::LayoutBusy; } WindowLayoutQueryAdapter::Ready(query) => query, From fff421a99ef0ec3aad2e9f3e7299027b67f9c790 Mon Sep 17 00:00:00 2001 From: vritser <16340478+vritser@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:09:18 +0800 Subject: [PATCH 2/2] fix(layout): address position query review findings - Do not retain Position observations in the geometry query cache. Position queries never read the cache, so their entries would only consume bounded capacity and evict reusable observations. - Propagate a deferred chrome throw or non-error signal at the Position query boundary before mapping the aborted layout to DidNotConverge, so an enclosing Lisp catch receives the original exit instead of a layout error. Adds regression tests for both behaviors. --- crates/neomacs-layout-engine/src/engine.rs | 12 ++-- .../tests/query_chrome_remeasure_test.rs | 67 +++++++++++++++++++ .../src/emacs_core/display/xdisp/mod.rs | 37 +++++++++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/neomacs-layout-engine/src/engine.rs b/crates/neomacs-layout-engine/src/engine.rs index d82b94a3bc..122202e3bc 100644 --- a/crates/neomacs-layout-engine/src/engine.rs +++ b/crates/neomacs-layout-engine/src/engine.rs @@ -4463,6 +4463,13 @@ impl LayoutEngine { scope, neovm_core::window::WindowLayoutQueryScope::TextExtent { .. } ); + // Position queries never read the cache (they remeasure chrome and + // line-number metric inputs). Do not let their observations consume + // bounded cache capacity or evict reusable rows either. + let position_query = matches!( + scope, + neovm_core::window::WindowLayoutQueryScope::Position { .. } + ); let certified_rows = matches!( (policy.environment(), policy.admission(), scope), ( @@ -4489,10 +4496,6 @@ impl LayoutEngine { // on point. A cached observation carries the text-area partition // from its own point, so re-placing its rows would reproduce stale // chrome bounds. Run the canonical walk instead. - let position_query = matches!( - scope, - neovm_core::window::WindowLayoutQueryScope::Position { .. } - ); if !text_extent && !position_query && let Some(query) = self.query_cache.get(evaluator, frame_id, window_id, scope) @@ -4631,6 +4634,7 @@ impl LayoutEngine { self.query_cache.remember_certified(evaluator, candidate); } } else if !text_extent + && !position_query && self.query_body_reuse_allowed && let Some(collections) = collections { diff --git a/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs b/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs index 001b322548..83061cbce6 100644 --- a/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs +++ b/crates/neomacs-layout-engine/src/engine/tests/query_chrome_remeasure_test.rs @@ -318,3 +318,70 @@ fn chrome_position_queries_do_not_reenter_chrome_or_signal_layout_busy() { )); } } + +#[test] +fn position_query_results_are_not_retained_in_the_query_cache() { + let (mut eval, frame, _buffer, window) = incr_editing_frame("line\nline\n", 800, 600); + eval.eval_str("(setq mode-line-format nil header-line-format nil tab-line-format nil)") + .unwrap(); + let mut engine = LayoutEngine::new_without_font_metrics(); + let position = WindowLayoutQueryScope::Position { + target: LispCharPos1::ONE, + }; + engine + .query_window_layout(&mut eval, frame, window, position) + .unwrap(); + assert!( + engine + .query_cache + .get(&eval, frame, window, position) + .is_none(), + "Position observations bypass cache reads and must not consume cache capacity" + ); + + // The cache still works for the scope that is allowed to read it. + let viewport = WindowLayoutQueryScope::Viewport; + engine + .query_window_layout(&mut eval, frame, window, viewport) + .unwrap(); + assert!( + engine + .query_cache + .get(&eval, frame, window, viewport) + .is_some(), + "window-end observations must still be cached" + ); +} + +#[test] +fn position_query_propagates_deferred_chrome_exit_to_lisp() { + use neovm_core::window::WindowLayoutQueryOutcome; + + let (mut eval, frame, _buffer, _window) = incr_editing_frame("line\nline\n", 800, 600); + eval.frame_manager_mut().get_mut(frame).unwrap().initial = false; + eval.eval_str( + "(progn + (setq mode-line-format nil tab-line-format nil + header-line-format '(:eval (throw 'chrome-exit 42))) + (goto-char 1))", + ) + .unwrap(); + let mut engine = WindowLayoutQueryEngine::new(); + eval.install_window_layout_query(move |eval, frame, window, scope| { + match engine.query_window_layout(eval, frame, window, scope) { + Ok(query) => WindowLayoutQueryOutcome::Ready(query), + Err(error) => WindowLayoutQueryOutcome::Failed(error), + } + }); + + assert_eq!( + eval.eval_str("(catch 'chrome-exit (posn-at-point 1))") + .unwrap(), + Value::fixnum(42), + "the chrome throw must reach the enclosing Lisp catch" + ); + assert!( + !eval.has_mode_line_display_flow(), + "the deferred flow must be consumed at the position-query boundary" + ); +} diff --git a/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs b/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs index 409f33fac0..bb6e3fd552 100644 --- a/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs @@ -8119,6 +8119,27 @@ impl PositionVisibility { } } +/// Take a deferred chrome exit before a Position query is mapped to a layout +/// error. GNU's redisplay handler propagates throws and non-error signals to +/// the enclosing Lisp `catch`; leaving the exit pending would turn it into +/// `DidNotConverge` and leak it into the next redisplay. +fn take_position_query_flow( + eval: &mut super::eval::Context, + scope: crate::window::WindowLayoutQueryScope, + query_failed: bool, +) -> Result<(), Flow> { + if query_failed + && matches!( + scope, + crate::window::WindowLayoutQueryScope::Position { .. } + ) + && let Some(flow) = eval.take_mode_line_display_flow() + { + return Err(flow); + } + Ok(()) +} + fn live_position_visibility( eval: &mut super::eval::Context, window: Option<&Value>, @@ -8221,7 +8242,13 @@ fn live_position_visibility( let scope = pos.map_or(crate::window::WindowLayoutQueryScope::Viewport, |target| { crate::window::WindowLayoutQueryScope::Position { target } }); - match eval.query_window_layout_scope(fid, wid, scope) { + let outcome = eval.query_window_layout_scope(fid, wid, scope); + take_position_query_flow( + eval, + scope, + matches!(&outcome, crate::window::WindowLayoutQueryOutcome::Failed(_)), + )?; + match outcome { crate::window::WindowLayoutQueryOutcome::Ready(query) => { Ok(query.into_geometry().as_ref().map(measure)) } @@ -9489,7 +9516,13 @@ fn compute_live_window_geometry_scope( { return Ok(None); } - match eval.query_window_layout_scope(fid, wid, scope) { + let outcome = eval.query_window_layout_scope(fid, wid, scope); + take_position_query_flow( + eval, + scope, + matches!(&outcome, crate::window::WindowLayoutQueryOutcome::Failed(_)), + )?; + match outcome { crate::window::WindowLayoutQueryOutcome::Ready(query) => Ok(query.into_geometry()), crate::window::WindowLayoutQueryOutcome::Unavailable => Ok(None), crate::window::WindowLayoutQueryOutcome::LayoutBusy => Err(signal(