diff --git a/crates/neomacs-gui-tests/fixtures/point-query-geometry/init.el b/crates/neomacs-gui-tests/fixtures/point-query-geometry/init.el new file mode 100644 index 0000000000..c9ed401156 --- /dev/null +++ b/crates/neomacs-gui-tests/fixtures/point-query-geometry/init.el @@ -0,0 +1,55 @@ +;;; Point-dependent geometry must be queried before redisplay. -*- lexical-binding: t; -*- +(require 'json) + +(defun point-query-geometry-probe () + (switch-to-buffer (get-buffer-create "*point-query-geometry*")) + (erase-buffer) + (dotimes (_ 200) (insert "a\n")) + (let ((out nil)) + (push (cons "native-engine" + (if (fboundp 'neomacs--write-frame-snapshot) t :json-false)) out) + (dolist (variable '(header-line-format tab-line-format mode-line-format)) + (setq-local header-line-format nil tab-line-format nil mode-line-format nil) + (setq-local display-line-numbers nil) + (goto-char 1) + (set-window-start (selected-window) 1) + (set (make-local-variable variable) + '((:eval (propertize "H" 'face + (if (= (point) 1) '(:height 1.0) '(:height 4.0)))))) + (redisplay t) + (let ((target 1)) + (while (and (< (+ target 2) (point-max)) + (pos-visible-in-window-p (+ target 2) nil nil)) + (setq target (+ target 2))) + (let ((before (posn-at-point target))) + (goto-char 3) + ;; Deliberately query before redisplay: point changes the header's + ;; height without changing the text or the chrome format's identity. + (push (cons (symbol-name variable) + (vector (if before t :json-false) + (if (posn-at-point target) t :json-false))) out)))) + (setq-local header-line-format nil tab-line-format nil mode-line-format nil) + (setq-local display-line-numbers t) + (set-face-attribute 'line-number-current-line nil :inherit 'line-number :height 2.0) + (goto-char 1) + (set-window-start (selected-window) 1) + (redisplay t) + (let ((before (posn-x-y (posn-at-point 3)))) + (goto-char 3) + (let ((after (posn-x-y (posn-at-point 3)))) + (push (cons "current-line-face" + (vector (car before) (cdr before) (car after) (cdr after))) out))) + (with-temp-file (getenv "NEOMACS_GUI_STATE_JSON") + (insert (json-encode (nreverse out))))) + (when (fboundp 'neomacs--write-frame-snapshot) + (neomacs--write-frame-snapshot + (concat (getenv "NEOMACS_GUI_STATE_JSON") ".frame.json") nil 'json)) + (kill-emacs 0)) + +(run-with-timer 1 nil + (lambda () + (condition-case err (point-query-geometry-probe) + (error + (with-temp-file (getenv "NEOMACS_GUI_STATE_JSON") + (insert (json-encode (list (cons "error" (format "%S" err)))))) + (kill-emacs 1))))) diff --git a/crates/neomacs-gui-tests/tests/gui.rs b/crates/neomacs-gui-tests/tests/gui.rs index ca68c1b44c..efb69d5efe 100644 --- a/crates/neomacs-gui-tests/tests/gui.rs +++ b/crates/neomacs-gui-tests/tests/gui.rs @@ -130,6 +130,9 @@ mod org_silent_selection; #[path = "pixel_measurement.rs"] mod pixel_measurement; +#[path = "point_query_geometry.rs"] +mod point_query_geometry; + #[path = "prefix_help.rs"] mod prefix_help; diff --git a/crates/neomacs-gui-tests/tests/point_query_geometry.rs b/crates/neomacs-gui-tests/tests/point_query_geometry.rs new file mode 100644 index 0000000000..b37c8be142 --- /dev/null +++ b/crates/neomacs-gui-tests/tests/point_query_geometry.rs @@ -0,0 +1,93 @@ +//! Live position queries must see point-dependent chrome and gutter metrics. +#![cfg(target_os = "linux")] +use neomacs_gui_tests::{ + CommandSpec, DisplayHarness, GuiArtifactSet, GuiBackend, GuiCommandRunner, GuiRunOptions, + GuiRunStatus, GuiScenario, GuiTestPlan, ProcessGuiCommandRunner, +}; +use std::{fs, path::PathBuf, time::Duration}; + +fn assert_geometry(state: &serde_json::Value) { + for variable in ["header-line-format", "tab-line-format", "mode-line-format"] { + assert_eq!( + state[variable], + serde_json::json!([true, false]), + "{variable}: {state}" + ); + } + let face = &state["current-line-face"]; + assert!( + face[1].as_i64().unwrap() > face[3].as_i64().unwrap(), + "moving the taller current-line face to row two must shorten row one: {state}" + ); +} + +#[test] +fn point_dependent_position_queries_match_live_gnu_before_redisplay() { + let root = neomacs_infra::workspace_root(); + let artifacts = root.join(format!("tmp/point-query-geometry-{}", std::process::id())); + let fixture = root.join("crates/neomacs-gui-tests/fixtures/point-query-geometry/init.el"); + let backend = match std::env::var("NEOMACS_GUI_TEST_BACKEND").as_deref() { + Ok("wayland") => GuiBackend::LinuxWayland, + _ => GuiBackend::LinuxX11, + }; + let gnu_session = DisplayHarness::for_backend(GuiBackend::LinuxX11) + .start_session(&artifacts.join("gnu")) + .unwrap(); + let gnu_state = artifacts.join("gnu-state.json"); + let mut command = CommandSpec { + program: std::env::var_os("NEOMACS_GNU_EMACS_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| "emacs".into()), + args: vec!["-Q".into(), "--load".into(), fixture.display().to_string()], + env: vec![ + ("GDK_BACKEND".into(), "x11".into()), + ("GSETTINGS_BACKEND".into(), "memory".into()), + ( + "NEOMACS_GUI_STATE_JSON".into(), + gnu_state.display().to_string(), + ), + ], + }; + command.env.extend(gnu_session.env().iter().cloned()); + let output = ProcessGuiCommandRunner + .run( + &command, + &GuiArtifactSet::new(&artifacts, GuiBackend::LinuxX11, "gnu"), + &GuiRunOptions::with_timeout(Duration::from_secs(30)), + ) + .unwrap(); + assert!(!output.timed_out, "GNU: {output:#?}"); + assert_eq!(output.exit_code, Some(0), "GNU: {output:#?}"); + let gnu: serde_json::Value = serde_json::from_slice(&fs::read(gnu_state).unwrap()).unwrap(); + assert_eq!(gnu["native-engine"], false); + assert_geometry(&gnu); + + let session = DisplayHarness::for_backend(backend) + .start_session(&artifacts.join("native")) + .unwrap(); + let binary = std::env::var_os("NEOMACS_GUI_TEST_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| root.join("target/release/neomacs")); + let mut plan = GuiTestPlan::new( + backend, + &root, + &artifacts, + GuiScenario::new("point-query-geometry", &fixture), + ) + .with_program(binary) + .with_env("NEOMACS_DEBUG_SURFACE_READBACK", "1"); + for (key, value) in session.env() { + plan = plan.with_env(key.clone(), value.clone()); + } + let result = plan + .run_with( + &mut ProcessGuiCommandRunner, + GuiRunOptions::with_timeout(Duration::from_secs(30)), + ) + .unwrap(); + assert_eq!(result.status, GuiRunStatus::Passed, "{result:#?}"); + let native: serde_json::Value = + serde_json::from_slice(&fs::read(&result.artifacts.gui_state).unwrap()).unwrap(); + assert_eq!(native["native-engine"], true); + assert_geometry(&native); +} 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..e04d413bd0 100644 --- a/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs +++ b/crates/neomacs-layout-engine/src/buffer_source/render_attempt.rs @@ -45,6 +45,15 @@ pub(crate) struct BufferSourceRenderAttemptContext< window_snapshots: &'a mut Vec, } +/// A Position query expands live chrome once. Only the immediately following +/// physical chrome-height retry may reuse its measured native dimensions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum PositionQueryChrome { + #[default] + Measure, + Measured, +} + /// Which live window state may be published by a completed row walk. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum WindowPositionPublication { @@ -74,6 +83,11 @@ pub(crate) enum WindowPositionPublication { /// start/end nor a window-scroll-functions effect to the live minibuffer. InactiveEchoArea, SynchronousQueryEnd, + /// GNU pos_visible_p remeasures live chrome before answering visibility. + /// Like window-end, this probe cannot publish a start/end or redisplay. + SynchronousPositionQuery { + chrome: PositionQueryChrome, + }, } impl WindowPositionPublication { @@ -84,15 +98,31 @@ 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::SynchronousPositionQuery { .. } ) } - /// 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. pub(crate) const fn is_synchronous_query(self) -> bool { - matches!(self, Self::SynchronousQueryEnd) + matches!( + self, + Self::SynchronousQueryEnd | Self::SynchronousPositionQuery { .. } + ) + } + + /// Preserve either the accepted window-end partition or the native heights + /// just measured for this position request. Initial position visibility + /// measures live mode/header/tab lines, as GNU pos_visible_p does. + pub(crate) const fn preserves_reserved_chrome(self) -> bool { + matches!( + self, + Self::SynchronousQueryEnd + | Self::SynchronousPositionQuery { + chrome: PositionQueryChrome::Measured, + } + ) } pub(crate) const fn keeps_complete_minibuffer_measurement_start(self) -> bool { @@ -133,7 +163,10 @@ impl WindowPositionPublication { window_start_lisp, ); } - Self::Snapshot | Self::InactiveEchoArea | Self::SynchronousQueryEnd => {} + Self::Snapshot + | Self::InactiveEchoArea + | Self::SynchronousQueryEnd + | Self::SynchronousPositionQuery { .. } => {} } None } @@ -440,7 +473,8 @@ impl BufferSourceRedisplayPublishRequest { // discarded attempt. WindowPositionPublication::Snapshot | WindowPositionPublication::InactiveEchoArea - | WindowPositionPublication::SynchronousQueryEnd => {} + | WindowPositionPublication::SynchronousQueryEnd + | WindowPositionPublication::SynchronousPositionQuery { .. } => {} } } 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..e49bd7aec0 100644 --- a/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs +++ b/crates/neomacs-layout-engine/src/buffer_source/render_plan.rs @@ -1852,12 +1852,11 @@ 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() { - // 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 - // query. This result is discarded rather than presented, so no - // chrome rows need to be emitted. + let chrome_source = if self.position_publication.preserves_reserved_chrome() { + // GNU `Fwindow_end` preserves the accepted text partition. A + // position query instead preserves its freshly measured native + // heights across this one physical convergence retry. Neither + // case needs chrome rows or another status-line Lisp evaluation. WindowChromeRowSource::PreserveReservedMetrics(WindowChromeMetrics::from_params(params)) } else { WindowChromeRowSource::Recompute diff --git a/crates/neomacs-layout-engine/src/display_status_line.rs b/crates/neomacs-layout-engine/src/display_status_line.rs index 4c36e351e0..d865764c60 100644 --- a/crates/neomacs-layout-engine/src/display_status_line.rs +++ b/crates/neomacs-layout-engine/src/display_status_line.rs @@ -144,6 +144,45 @@ thread_local! { std::cell::RefCell::new(HashMap::new()); } +/// A position probe may shape live chrome, but cannot replace records owned +/// by the enclosing redisplay. All stored state is native; no Lisp output is +/// retained across a physical query retry. +#[must_use] +pub(crate) struct ChromeQueryRecordScope { + generation: HashMap, + mode_count: u32, + header_count: u32, + tab_count: u32, + _fingerprints: crate::incremental_layout::chrome_memo::ChromeFingerprintRecordScope, + _columns: neovm_core::emacs_core::xdisp::ColumnSpecConsumptionScope, +} + +impl ChromeQueryRecordScope { + pub(crate) fn isolate() -> Self { + Self { + generation: CHROME_GENERATION_RECORD + .with(|record| std::mem::take(&mut *record.borrow_mut())), + mode_count: MODE_LINE_EVAL_COUNT.with(|count| count.replace(0)), + header_count: HEADER_LINE_EVAL_COUNT.with(|count| count.replace(0)), + tab_count: TAB_LINE_EVAL_COUNT.with(|count| count.replace(0)), + _fingerprints: + crate::incremental_layout::chrome_memo::ChromeFingerprintRecordScope::isolate(), + _columns: neovm_core::emacs_core::xdisp::ColumnSpecConsumptionScope::isolate(), + } + } +} + +impl Drop for ChromeQueryRecordScope { + fn drop(&mut self) { + CHROME_GENERATION_RECORD.with(|record| { + std::mem::swap(&mut *record.borrow_mut(), &mut self.generation); + }); + MODE_LINE_EVAL_COUNT.with(|count| count.set(self.mode_count)); + HEADER_LINE_EVAL_COUNT.with(|count| count.set(self.header_count)); + TAB_LINE_EVAL_COUNT.with(|count| count.set(self.tab_count)); + } +} + /// Reset the per-redisplay chrome generation record. Called with /// [`reset_mode_line_eval_count`] at the start of `layout_frame_rust`. pub(crate) fn reset_chrome_generation_record() { diff --git a/crates/neomacs-layout-engine/src/engine.rs b/crates/neomacs-layout-engine/src/engine.rs index 45304f96a7..392ba6bba4 100644 --- a/crates/neomacs-layout-engine/src/engine.rs +++ b/crates/neomacs-layout-engine/src/engine.rs @@ -48,7 +48,9 @@ use super::gui_chrome::{ use super::types::*; #[cfg(test)] use super::window_output::RowMetricsSnapshot; -use crate::buffer_source::render_attempt::{QueryRowCoverage, WindowPositionPublication}; +use crate::buffer_source::render_attempt::{ + PositionQueryChrome, QueryRowCoverage, WindowPositionPublication, +}; use crate::buffer_source::window_geometry::BufferWindowGeometryRequest; use crate::buffer_source::window_render::{ BufferSourceRenderAttemptContext, BufferSourceRenderAttemptOutcome, BufferWindowRenderRequest, @@ -387,12 +389,18 @@ fn window_position_publication( params: &WindowParams, purpose: WindowLayoutWalkPurpose, source: WindowDisplaySource, + chrome: PositionQueryChrome, ) -> WindowPositionPublication { - if matches!( - purpose, - WindowLayoutWalkPurpose::SynchronousQuery(_) | WindowLayoutWalkPurpose::MiniPreparation - ) { - return WindowPositionPublication::SynchronousQueryEnd; + match purpose { + WindowLayoutWalkPurpose::SynchronousQuery( + neovm_core::window::WindowLayoutQueryScope::Position { .. }, + ) => { + return WindowPositionPublication::SynchronousPositionQuery { chrome }; + } + WindowLayoutWalkPurpose::SynchronousQuery(_) | WindowLayoutWalkPurpose::MiniPreparation => { + return WindowPositionPublication::SynchronousQueryEnd; + } + WindowLayoutWalkPurpose::Redisplay | WindowLayoutWalkPurpose::Snapshot => {} } if source == WindowDisplaySource::InactiveEchoArea { return WindowPositionPublication::InactiveEchoArea; @@ -1997,6 +2005,7 @@ impl LayoutEngine { ..RedisplayLispLedger::default() }; let mut window_chrome_metrics = self.retained_window_chrome_metrics.clone(); + let mut next_position_query_chrome = PositionQueryChrome::Measure; let committed_face_arena = self .frame_face_arenas .get(&frame_id) @@ -2029,6 +2038,7 @@ impl LayoutEngine { accepted_window_navigation_intents, accepted_frame_navigation_intent, ) = 'frame_layout: loop { + let position_query_chrome = std::mem::take(&mut next_position_query_chrome); // 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. @@ -2703,6 +2713,7 @@ impl LayoutEngine { &live_inputs.window, layout_walk_purpose, live_inputs.source, + position_query_chrome, ), WindowPositionPublication::RedisplayMinibufferMeasurement ) @@ -2717,7 +2728,22 @@ impl LayoutEngine { &live_inputs.window, layout_walk_purpose, live_inputs.source, + position_query_chrome, ); + if matches!( + position_publication, + WindowPositionPublication::SynchronousPositionQuery { + chrome: PositionQueryChrome::Measure, + } + ) && (live_inputs.window.mode_line_height > 0.0 + || live_inputs.window.header_line_height > 0.0 + || live_inputs.window.tab_line_height > 0.0) + { + // Status-line Lisp can read arbitrary scalar variables. + // Collection/source certificates cannot memoize it, even + // when a physical retry later emits no chrome strings. + self.query_body_reuse_allowed = false; + } let mut window_geometry = WindowFrameGeometryRequest::new( &live_inputs.window, &live_inputs.frame, @@ -2951,6 +2977,7 @@ impl LayoutEngine { &live_inputs.window, layout_walk_purpose, live_inputs.source, + position_query_chrome, ); is_edit = false; // A hook effect re-enters this leaf from its live @@ -3036,6 +3063,14 @@ impl LayoutEngine { } window_chrome_metrics .insert(DisplayWindowId::new(params.window_id), measured); + if matches!( + position_publication, + WindowPositionPublication::SynchronousPositionQuery { + chrome: PositionQueryChrome::Measure, + } + ) { + next_position_query_chrome = PositionQueryChrome::Measured; + } continue 'frame_layout; } }; @@ -4451,6 +4486,11 @@ impl LayoutEngine { return Ok(query); } } + let _chrome_records = matches!( + scope, + neovm_core::window::WindowLayoutQueryScope::Position { .. } + ) + .then(crate::display_status_line::ChromeQueryRecordScope::isolate); self.query_body_reuse_allowed = true; self.query_restart_rows.clear(); self.query_row_coverage = QueryRowCoverage::Complete; diff --git a/crates/neomacs-layout-engine/src/engine/query_cache.rs b/crates/neomacs-layout-engine/src/engine/query_cache.rs index e3ddbe60dd..2910125cf2 100644 --- a/crates/neomacs-layout-engine/src/engine/query_cache.rs +++ b/crates/neomacs-layout-engine/src/engine/query_cache.rs @@ -150,6 +150,16 @@ impl QueryCache { return None; } let current = evaluator.window_display_snapshot_freshness(frame, window, buffer)?; + let chrome_absent = { + let frame = evaluator.frame_manager().get(frame)?; + neovm_core::window::WindowChromePresence::resolve( + frame.find_window(window)?, + evaluator.buffer_manager().get(buffer)?, + frame, + frame.minibuffer_window == Some(window), + ) + .is_empty() + }; let source_point = evaluator .buffer_manager() .get(buffer)? @@ -192,10 +202,13 @@ impl QueryCache { // A later target completes all earlier exact source points. A // hidden target resolved through a neighbor is insufficient: the // canonical earlier stop might not yet have emitted that neighbor. - let covers_scope = entry.scope == scope || matches!( + // A viewport/window-end observation preserves accepted chrome. + // Position visibility requires live chrome measurement, so that + // policy can be upgraded only when no chrome exists to measure. + let covers_scope = entry.scope == scope || (chrome_absent && matches!( (entry.scope, scope), (WindowLayoutQueryScope::Viewport, WindowLayoutQueryScope::Position { .. }) - ) || matches!( + )) || matches!( (entry.scope, scope), (WindowLayoutQueryScope::Position { target: cached_target }, WindowLayoutQueryScope::Position { target }) diff --git a/crates/neomacs-layout-engine/src/engine/tests/chrome_memo_engine_test.rs b/crates/neomacs-layout-engine/src/engine/tests/chrome_memo_engine_test.rs index 7a48bc02e9..5c61617724 100644 --- a/crates/neomacs-layout-engine/src/engine/tests/chrome_memo_engine_test.rs +++ b/crates/neomacs-layout-engine/src/engine/tests/chrome_memo_engine_test.rs @@ -11,10 +11,10 @@ use crate::incremental_layout::chrome_memo::{ CHROME_MEMO_VERIFY_MISMATCHES, ChromeMemoMode, set_chrome_memo_mode_for_test, }; -struct MemoGuard; +pub(super) struct MemoGuard; impl MemoGuard { - fn set(mode: ChromeMemoMode) -> Self { + pub(super) fn set(mode: ChromeMemoMode) -> Self { set_chrome_memo_mode_for_test(Some(mode)); MemoGuard } 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 bd65d403cd..35cae587e2 100644 --- a/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs +++ b/crates/neomacs-layout-engine/src/engine/tests/engine_test.rs @@ -37773,3 +37773,168 @@ mod overlay_string_window_wrap_test; #[cfg(test)] #[path = "narrow_root_eob_test.rs"] mod narrow_root_eob_test; + +#[test] +fn position_query_remeasures_point_dependent_chrome_without_publishing() { + use neovm_core::window::WindowLayoutQueryScope; + for prime_end_query in [false, true] { + let (mut eval, frame, _buffer, window) = incr_editing_frame(&"a\n".repeat(200), 320, 160); + assert!(eval.frame_manager_mut().select_frame(frame)); + eval.frame_manager_mut() + .get_mut(frame) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + eval.set_variable("point-query-window", Value::make_window(window.0)); + eval.eval_str( + r#"(progn + (setq mode-line-format nil tab-line-format nil query-chrome-evals 0) + (setq header-line-format + '((:eval (propertize (progn (setq query-chrome-evals (1+ query-chrome-evals)) "H") 'face + (if (= (point) 1) '(:height 1.0) '(:height 4.0)))))) + (goto-char 1))"#, + ) + .unwrap(); + let mut engine = LayoutEngine::new(); + engine.layout_frame_rust(&mut eval, frame); + let warm = eval + .frame_manager() + .get(frame) + .unwrap() + .redisplay_snapshot(window) + .unwrap() + .clone(); + let target = warm + .rows + .last() + .and_then(|row| row.start_buffer_pos) + .expect("last visible text row"); + assert!(warm.point_for_buffer_pos(target).is_some()); + eval.eval_str("(progn (goto-char 3) (set-window-point point-query-window (point)) (setq query-chrome-evals 0))") + .unwrap(); + if prime_end_query { + let end_query = engine + .query_window_layout(&mut eval, frame, window, WindowLayoutQueryScope::Viewport) + .expect("window-end query converges"); + assert_eq!( + end_query.geometry().unwrap().header_line_height, + warm.header_line_height, + "window-end preserves accepted chrome dimensions" + ); + assert_eq!( + eval.eval_str("query-chrome-evals").unwrap(), + Value::fixnum(0), + "window-end must not evaluate chrome" + ); + } + let query = engine + .query_window_layout( + &mut eval, + frame, + window, + WindowLayoutQueryScope::Position { target }, + ) + .expect("position query converges"); + let geometry = query.geometry().unwrap(); + assert!( + geometry.header_line_height > warm.header_line_height, + "GNU pos_visible_p measures the live header before walking the body; preceding window-end={prime_end_query}" + ); + assert!( + geometry.point_for_buffer_pos(target).is_none(), + "the larger header hides the old last body row" + ); + assert_eq!( + eval.frame_manager() + .get(frame) + .unwrap() + .redisplay_snapshot(window), + Some(&warm), + "a position query must not replace accepted redisplay geometry" + ); + assert_eq!( + eval.eval_str("query-chrome-evals").unwrap(), + Value::fixnum(1), + "physical chrome-height convergence must not repeat observable Lisp evaluation" + ); + engine + .query_window_layout( + &mut eval, + frame, + window, + WindowLayoutQueryScope::Position { target }, + ) + .expect("a later position request measures chrome again"); + assert_eq!( + eval.eval_str("query-chrome-evals").unwrap(), + Value::fixnum(2), + "request-local measured heights must not certify a later query cache hit" + ); + } +} + +#[test] +fn position_query_preserves_enclosing_chrome_tracking_records() { + use crate::incremental_layout::chrome_memo::{ChromeMemoMode, recorded_fingerprints}; + use neovm_core::window::WindowLayoutQueryScope; + let _memo = chrome_memo_engine_test::MemoGuard::set(ChromeMemoMode::On); + let (mut eval, frame, buffer, window) = incr_editing_frame("a\nb\n", 320, 160); + assert!(eval.frame_manager_mut().select_frame(frame)); + eval.buffer_manager_mut() + .get_mut(buffer) + .unwrap() + .set_buffer_local("mode-line-format", Value::string("%c")); + let mut engine = LayoutEngine::new(); + engine.layout_frame_rust(&mut eval, frame); + assert!( + neovm_core::emacs_core::xdisp::column_spec_consumed(), + "fixture displayed a column" + ); + let count = crate::display_status_line::mode_line_eval_count(); + let fingerprints = recorded_fingerprints(window.0 as i64); + assert!( + fingerprints.is_some(), + "fixture must carry a chrome fingerprint" + ); + let generation = || { + let mut entries: Vec<_> = crate::display_status_line::chrome_generation_record() + .into_iter() + .map(|(id, record)| (id, record.uses_column)) + .collect(); + entries.sort_unstable(); + entries + }; + let generated = generation(); + eval.buffer_manager_mut() + .get_mut(buffer) + .unwrap() + .set_buffer_local("mode-line-format", Value::string("FIXED")); + engine + .query_window_layout( + &mut eval, + frame, + window, + WindowLayoutQueryScope::Position { + target: LispCharPos1::ONE, + }, + ) + .unwrap(); + assert!( + neovm_core::emacs_core::xdisp::column_spec_consumed(), + "an unpublished query must preserve its caller's column-consumption record" + ); + assert_eq!( + crate::display_status_line::mode_line_eval_count(), + count, + "query evaluations do not belong to the enclosing redisplay's counter" + ); + assert_eq!( + generation(), + generated, + "a query must not acknowledge enclosing chrome generation" + ); + assert_eq!( + recorded_fingerprints(window.0 as i64), + fingerprints, + "a query must not replace the enclosing redisplay's chrome fingerprints" + ); +} 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..7f9cca5fba 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 @@ -4,6 +4,8 @@ use neovm_core::window::{FrameId, WindowId, WindowLayoutQueryScope}; fn query_fixture(text: &str, setup: &str, start: usize) -> (Context, FrameId, WindowId) { let (mut eval, frame, buffer, window) = incr_editing_frame(text, 80, 24); + eval.eval_str("(setq mode-line-format nil header-line-format nil tab-line-format nil)") + .expect("source horizon fixture has only body rows"); eval.eval_str(setup).expect("source properties"); { let frame = eval.frame_manager_mut().get_mut(frame).unwrap(); diff --git a/crates/neomacs-layout-engine/src/incremental_layout/chrome_memo.rs b/crates/neomacs-layout-engine/src/incremental_layout/chrome_memo.rs index 6861c6da0d..b7ef8f272a 100644 --- a/crates/neomacs-layout-engine/src/incremental_layout/chrome_memo.rs +++ b/crates/neomacs-layout-engine/src/incremental_layout/chrome_memo.rs @@ -652,6 +652,31 @@ thread_local! { > = std::cell::RefCell::new(rustc_hash::FxHashMap::default()); } +/// Request-local fingerprints; dropping the scope restores the enclosing +/// redisplay's native record, including on a failed query or unwind. +#[must_use] +pub(crate) struct ChromeFingerprintRecordScope { + previous: rustc_hash::FxHashMap>, + _thread: std::marker::PhantomData>, +} + +impl ChromeFingerprintRecordScope { + pub(crate) fn isolate() -> Self { + Self { + previous: FINGERPRINT_RECORD.with(|record| std::mem::take(&mut *record.borrow_mut())), + _thread: std::marker::PhantomData, + } + } +} + +impl Drop for ChromeFingerprintRecordScope { + fn drop(&mut self) { + FINGERPRINT_RECORD.with(|record| { + std::mem::swap(&mut *record.borrow_mut(), &mut self.previous); + }); + } +} + /// Forget the previous layout's record (start of a frame layout). pub(crate) fn reset_fingerprint_record() { FINGERPRINT_RECORD.with(|record| record.borrow_mut().clear()); 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 29551a07d5..5a6443e665 100644 --- a/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/xdisp/mod.rs @@ -457,6 +457,30 @@ fn note_column_spec_consumed() { COLUMN_SPEC_CONSUMED.with(|consumed| consumed.set(true)); } +/// Keep a nested geometry probe from changing its caller's column detector. +#[must_use] +pub struct ColumnSpecConsumptionScope { + previous: bool, + _thread: std::marker::PhantomData>, +} + +impl ColumnSpecConsumptionScope { + pub fn isolate() -> Self { + Self { + previous: COLUMN_SPEC_CONSUMED.with(|value| value.replace(false)), + _thread: std::marker::PhantomData, + } + } +} + +impl Drop for ColumnSpecConsumptionScope { + fn drop(&mut self) { + COLUMN_SPEC_CONSUMED.with(|value| value.set(self.previous)); + } +} + +static_assertions::assert_not_impl_any!(ColumnSpecConsumptionScope: Send, Sync); + /// Arm the `%c`/`%C` detector for one mode-line expansion. pub fn reset_column_spec_consumed() { COLUMN_SPEC_CONSUMED.with(|consumed| consumed.set(false)); @@ -8639,11 +8663,8 @@ fn retained_rows_answer_for_live_window( return Ok(true); } // A command can move point and ask for position geometry before redisplay. - // Reuse only when row inputs, including overlay revisions, still match. - // Relative and visual line numbers follow point and keep the canonical walk. - if !line_numbers_are_point_independent(eval, buffer_id) { - return Ok(false); - } + // Reuse only when the canonical token certifies point-independent row + // geometry and all other inputs, including overlay revisions, still match. let Some(current) = eval.window_display_snapshot_freshness(fid, wid, buffer_id) else { return Ok(false); }; @@ -8655,28 +8676,6 @@ fn retained_rows_answer_for_live_window( .is_some_and(|recorded| recorded.same_position_rows_ignoring_point(¤t))) } -/// Whether display line numbers in BUFFER leave row geometry independent of -/// point. Only nil and absolute numbering qualify; relative/visual modes derive -/// every gutter value from point. -fn line_numbers_are_point_independent( - eval: &super::eval::Context, - buffer_id: crate::buffer::BufferId, -) -> bool { - let Some(buffer) = eval.buffers.get(buffer_id) else { - return true; - }; - let Some(value) = eval.variable_value_in_buffer( - Some(buffer), - crate::emacs_core::intern::intern("display-line-numbers"), - ) else { - return true; - }; - if value.is_nil() || value == Value::T { - return true; - } - matches!(value.as_bare_symbol_name().as_deref(), Some("absolute")) -} - fn resolve_exact_visible_metrics( frames: &crate::window::FrameManager, buffers: &crate::buffer::BufferManager, 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 ca7f2d5289..8c47c5ddf6 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 @@ -4085,26 +4085,49 @@ fn posn_at_point_queries_the_target_instead_of_completing_the_viewport() { } #[test] -fn line_numbers_point_independence_uses_defaults_and_local_overrides() { +fn point_only_row_reuse_honors_default_and_local_line_number_settings() { for (setup, expected) in [ ("(set-default 'display-line-numbers 'relative)", false), ("(set-default 'display-line-numbers 'visual)", false), ("(set-default 'display-line-numbers nil)", true), - ("(set-default 'display-line-numbers t)", true), + ("(set-default 'display-line-numbers t)", false), ( "(progn (set-default 'display-line-numbers 'relative) (set (make-local-variable 'display-line-numbers) nil))", true, ), ( "(progn (set-default 'display-line-numbers 'relative) (set (make-local-variable 'display-line-numbers) t))", - true, + false, ), ] { 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("line-number-point-reuse", 160, 96, buffer); + let window = eval.frames.get(frame).unwrap().selected_window; eval.eval_str(setup).unwrap(); + let before = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + eval.eval_str("(goto-char 3)").unwrap(); + match eval + .frames + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + crate::window::Window::Leaf { point, .. } => *point = LispCharPos1::new(3), + other => panic!("expected leaf window, got {other:?}"), + } + let after = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + assert_ne!(before, after, "the fixture must move window point"); assert_eq!( - line_numbers_are_point_independent(&eval, buffer), + before.same_position_rows_ignoring_point(&after), expected, "{setup}" ); @@ -4191,7 +4214,6 @@ fn posn_at_point_requeries_warm_rows_after_point_moves_with_default_relative_num freshness, moved, "the fixture must change snapshot freshness" ); - assert!(freshness.same_position_rows_ignoring_point(&moved)); query(&mut eval); assert_eq!( scopes.borrow().as_slice(), @@ -4202,6 +4224,207 @@ fn posn_at_point_requeries_warm_rows_after_point_moves_with_default_relative_num ); } +#[test] +fn posn_at_point_requeries_warm_rows_when_point_changes_header_height() { + 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-header-query", 160, 96, buffer); + let window = eval.frames.get(frame).unwrap().selected_window; + eval.frames.get_mut(frame).unwrap().initial = false; + eval.eval_str( + r#"(progn + (goto-char 1) + (setq header-line-format + '((:eval (propertize "H" 'face + (if (= (point) 1) '(:height 1.0) '(:height 4.0)))))))"#, + ) + .unwrap(); + let freshness = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + let warm = crate::window::WindowDisplaySnapshot { + window_id: window, + header_line_height: 16, + points: vec![crate::window::DisplayPointSnapshot { + role: crate::window::DisplayPointRole::Glyph, + buffer_pos: LispCharPos1::new(3), + x: 0, + y: 32, + width: 8, + height: 16, + row: 1, + col: 0, + }], + rows: vec![crate::window::DisplayRowSnapshot { + row: 1, + y: 32, + height: 16, + start_buffer_pos: Some(LispCharPos1::new(3)), + end_buffer_pos: Some(LispCharPos1::new(4)), + ..Default::default() + }], + layout_freshness: Some(freshness.clone()), + ..Default::default() + }; + eval.frames + .get_mut(frame) + .unwrap() + .commit_redisplay_cache_for_test(vec![warm]); + 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(), + "the last body row is initially visible" + ); + // The layout provider's independently remeasured header consumes the last + // body row. No visible glyph remains for position 3 after point moves. + eval.install_window_layout_query(move |_, fid, wid, scope| { + assert_eq!((fid, wid), (frame, window)); + assert_eq!( + scope, + WindowLayoutQueryScope::Position { + target: LispCharPos1::new(3) + } + ); + WindowLayoutQueryOutcome::Ready(WindowLayoutQuery::new( + LispCharPos1::ONE, + Some(crate::window::WindowDisplaySnapshot { + window_id: window, + header_line_height: 64, + ..Default::default() + }), + )) + }); + eval.eval_str("(goto-char 3)").unwrap(); + match eval + .frames + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + crate::window::Window::Leaf { point, .. } => *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, + "the fixture must change canonical freshness" + ); + assert!( + query(&mut eval).is_nil(), + "a taller point-dependent header must hide the last body row" + ); +} + +#[test] +fn posn_at_point_requeries_absolute_numbers_when_current_line_face_is_taller() { + 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("current-line-face-query", 160, 96, buffer); + let window = eval.frames.get(frame).unwrap().selected_window; + eval.frames.get_mut(frame).unwrap().initial = false; + eval.eval_str("(progn (setq display-line-numbers t) (goto-char 1) (internal-make-lisp-face 'line-number-current-line))").unwrap(); + crate::emacs_core::xfaces::builtin_internal_set_lisp_face_attribute( + &mut eval, + vec![ + Value::symbol("line-number-current-line"), + Value::keyword("height"), + Value::make_float(2.0), + Value::make_frame(frame.0), + ], + ) + .unwrap(); + let freshness = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + let snapshot = |y| crate::window::WindowDisplaySnapshot { + window_id: window, + points: vec![crate::window::DisplayPointSnapshot { + role: crate::window::DisplayPointRole::Glyph, + buffer_pos: LispCharPos1::new(3), + x: 0, + y, + width: 8, + height: 16, + row: 1, + col: 0, + }], + rows: vec![crate::window::DisplayRowSnapshot { + row: 1, + y, + height: 16, + start_buffer_pos: Some(LispCharPos1::new(3)), + end_buffer_pos: Some(LispCharPos1::new(4)), + ..Default::default() + }], + ..Default::default() + }; + let mut warm = snapshot(32); + warm.layout_freshness = Some(freshness.clone()); + eval.frames + .get_mut(frame) + .unwrap() + .commit_redisplay_cache_for_test(vec![warm]); + let query = |eval: &mut Context| { + builtin_posn_at_point(eval, vec![Value::fixnum(3), Value::make_window(window.0)]) + .unwrap() + .cons_cdr() + .cons_cdr() + .cons_car() + }; + assert_eq!( + query(&mut eval), + Value::cons(Value::fixnum(0), Value::fixnum(32)) + ); + let refreshed = snapshot(16); + eval.install_window_layout_query(move |_, fid, wid, scope| { + assert_eq!((fid, wid), (frame, window)); + assert_eq!( + scope, + WindowLayoutQueryScope::Position { + target: LispCharPos1::new(3) + } + ); + WindowLayoutQueryOutcome::Ready(WindowLayoutQuery::new( + LispCharPos1::ONE, + Some(refreshed.clone()), + )) + }); + eval.eval_str("(goto-char 3)").unwrap(); + match eval + .frames + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + crate::window::Window::Leaf { point, .. } => *point = LispCharPos1::new(3), + other => panic!("expected leaf window, got {other:?}"), + } + assert_ne!( + freshness, + eval.window_display_snapshot_freshness(frame, window, buffer) + .unwrap() + ); + assert_eq!( + query(&mut eval), + Value::cons(Value::fixnum(0), Value::fixnum(16)), + "the preceding row becomes shorter when the tall current-line face moves away" + ); +} + #[test] fn posn_at_point_requeries_warm_rows_after_face_overlay_height_changes_or_moves() { use crate::window::{WindowLayoutQuery, WindowLayoutQueryOutcome, WindowLayoutQueryScope}; diff --git a/crates/neovm-core/src/window/chrome.rs b/crates/neovm-core/src/window/chrome.rs index be0688dc11..e499c97913 100644 --- a/crates/neovm-core/src/window/chrome.rs +++ b/crates/neovm-core/src/window/chrome.rs @@ -5,6 +5,15 @@ use super::{Frame, Window, WindowChromeLine, WindowLayoutVariable}; use crate::buffer::Buffer; +use crate::emacs_core::value::{Value, borrow_string_text_properties_table_for_value}; + +/// Proof required before a position query may discard a point change. +/// Unknown or executable chrome must be measured by the layout adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PointGeometryReuse { + CertifiedIndependent, + RequiresMeasurement, +} #[derive(Clone, Copy, Debug, Default)] pub struct WindowChromePresence { @@ -14,6 +23,10 @@ pub struct WindowChromePresence { } impl WindowChromePresence { + pub const fn is_empty(self) -> bool { + !(self.mode || self.header || self.tab) + } + pub fn resolve(window: &Window, buffer: &Buffer, frame: &Frame, minibuffer: bool) -> Self { if !window.is_leaf() || minibuffer { return Self::default(); @@ -40,6 +53,56 @@ impl WindowChromePresence { Self { mode, header, tab } } + pub(super) fn point_geometry_reuse( + self, + window: &Window, + buffer: &Buffer, + ) -> PointGeometryReuse { + for (line, variable) in [ + ( + WindowChromeLine::ModeLine, + WindowLayoutVariable::ModeLineFormat, + ), + ( + WindowChromeLine::HeaderLine, + WindowLayoutVariable::HeaderLineFormat, + ), + ( + WindowChromeLine::TabLine, + WindowLayoutVariable::TabLineFormat, + ), + ] { + if !self.contains(line) { + continue; + } + let symbol = variable.sym_id(); + let format = window + .parameters() + .iter() + .find(|(key, _)| key.as_bare_symbol_id() == Some(symbol)) + .map(|(_, value)| *value) + .filter(|value| !value.is_nil()) + .or_else(|| buffer.buffer_local_value_id(symbol)) + .unwrap_or(Value::NIL); + // A literal, property-free string has no executable display forms. + // Admit only literal text, escaped percent signs and dash fill; + // other percent substitutions can depend on point or Lisp values. + let Some(text) = format.as_utf8_str() else { + return PointGeometryReuse::RequiresMeasurement; + }; + if borrow_string_text_properties_table_for_value(format).is_some() { + return PointGeometryReuse::RequiresMeasurement; + } + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '%' && !matches!(chars.next(), Some('%' | '-')) { + return PointGeometryReuse::RequiresMeasurement; + } + } + } + PointGeometryReuse::CertifiedIndependent + } + pub const fn contains(self, line: WindowChromeLine) -> bool { match line { WindowChromeLine::ModeLine => self.mode, diff --git a/crates/neovm-core/src/window/display.rs b/crates/neovm-core/src/window/display.rs index baa22040d5..33e84556bc 100644 --- a/crates/neovm-core/src/window/display.rs +++ b/crates/neovm-core/src/window/display.rs @@ -984,10 +984,19 @@ impl crate::emacs_core::eval::Context { buffer_id: BufferId, ) -> Option { let frame = self.frames.get(frame_id)?; - let window = frame.window_layout_inputs(window_id)?; + let mut window = frame.window_layout_inputs(window_id)?; if window.buffer_id != buffer_id { return None; } + // GNU window_point reads BUF_PT for the globally selected window, + // even before its saved marker is synchronized by the query adapter. + // Freshness admission happens first, so it must observe that same live + // point without mutating markers or borrowing the adapter. + if self.frames.selected_frame().is_some_and(|selected| { + selected.id == frame_id && selected.selected_window == window_id + }) { + window.point = self.buffers.get(buffer_id)?.point_lisp_char_pos(); + } Some(WindowDisplaySnapshotFreshness { fontset_generation: crate::emacs_core::fontset::fontset_generation(), context_instance_id: self.context_instance_id(), @@ -1006,6 +1015,32 @@ impl crate::emacs_core::eval::Context { .buffers .get(buffer_id) .is_some_and(|buffer| buffer.overlays_are_face_only()), + point_geometry_reuse: { + let live_window = frame.find_window(window_id)?; + let buffer = self.buffers.get(buffer_id)?; + // Absolute numbers are not a geometry proof: moving point + // changes the row receiving line-number-current-line, whose + // font can change both the gutter width and the row height. + // Until the producer certifies equivalent face metrics, all + // active numbering requires the canonical targeted query. + if self + .variable_value_in_buffer( + Some(buffer), + super::WindowLayoutVariable::DisplayLineNumbers.sym_id(), + ) + .is_some_and(|value| value.is_truthy()) + { + super::PointGeometryReuse::RequiresMeasurement + } else { + super::WindowChromePresence::resolve( + live_window, + buffer, + frame, + frame.minibuffer_window == Some(window_id), + ) + .point_geometry_reuse(live_window, buffer) + } + }, }) } diff --git a/crates/neovm-core/src/window/mod.rs b/crates/neovm-core/src/window/mod.rs index e0834cbe31..8cc718d65d 100644 --- a/crates/neovm-core/src/window/mod.rs +++ b/crates/neovm-core/src/window/mod.rs @@ -22,6 +22,7 @@ use std::sync::Arc; pub(crate) mod body; mod chrome; +pub(crate) use chrome::PointGeometryReuse; pub use chrome::WindowChromePresence; mod display; pub(crate) use display::{ @@ -3125,6 +3126,7 @@ pub struct WindowDisplaySnapshotFreshness { /// captured. This excludes structural overlays from point-only query reuse; /// it does not prove that overlay modifications preserve row geometry. pub(crate) overlays_face_only: bool, + pub(crate) point_geometry_reuse: PointGeometryReuse, } impl WindowDisplaySnapshotFreshness { @@ -3159,8 +3161,14 @@ impl WindowDisplaySnapshotFreshness { if !(self.overlays_face_only && current.overlays_face_only) { return false; } + if self.window.point != current.window.point + && (self.point_geometry_reuse != PointGeometryReuse::CertifiedIndependent + || current.point_geometry_reuse != PointGeometryReuse::CertifiedIndependent) + { + return false; + } let mut expected = current.clone(); - // Cursor decoration only. + // Point is cursor decoration only after both tokens certify it. expected.window.point = self.window.point; // A request to repaint is not a body-content mutation. expected.redisplay_generation = self.redisplay_generation; diff --git a/crates/neovm-core/src/window/tests/window_test.rs b/crates/neovm-core/src/window/tests/window_test.rs index 3249166ec5..93aa9d7f9d 100644 --- a/crates/neovm-core/src/window/tests/window_test.rs +++ b/crates/neovm-core/src/window/tests/window_test.rs @@ -4584,3 +4584,86 @@ fn positions_left_of_a_rows_first_glyph_are_hidden_by_hscroll() { ); } } + +#[test] +fn selected_window_query_freshness_uses_live_point_before_marker_sync() { + for switch_current_buffer in [false, true] { + let mut eval = crate::emacs_core::Context::new(); + let buffer = eval.buffer_manager().current_buffer().unwrap().id(); + eval.buffer_manager_mut() + .get_mut(buffer) + .unwrap() + .insert("a\nb\n"); + let frame = eval + .frame_manager_mut() + .create_frame("live-query-point", 160, 96, buffer); + let window = eval.frame_manager().get(frame).unwrap().selected_window; + assert!(eval.frame_manager_mut().select_frame(frame)); + eval.eval_str("(goto-char 1)").unwrap(); + match eval + .frame_manager_mut() + .get_mut(frame) + .unwrap() + .find_window_mut(window) + .unwrap() + { + Window::Leaf { point, .. } => *point = LispCharPos1::ONE, + other => panic!("expected leaf window, got {other:?}"), + } + let other_frame = + eval.frame_manager_mut() + .create_frame("saved-query-point", 160, 96, buffer); + let other_window = eval + .frame_manager() + .get(other_frame) + .unwrap() + .selected_window; + assert!(eval.frame_manager_mut().select_frame(frame)); + if switch_current_buffer { + eval.eval_str("(get-buffer-create \"*unrelated-query-buffer*\")") + .unwrap(); + } + let other_before = eval + .window_display_snapshot_freshness(other_frame, other_window, buffer) + .unwrap(); + let before = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + eval.eval_str("(goto-char 3)").unwrap(); + if switch_current_buffer { + eval.eval_str("(set-buffer \"*unrelated-query-buffer*\")") + .unwrap(); + } + // Like GNU's selected window, the buffer owns live point while the + // saved window marker still describes the previous redisplay. + assert_eq!( + eval.frame_manager() + .get(frame) + .unwrap() + .window_layout_inputs(window) + .unwrap() + .point, + LispCharPos1::ONE, + "the fixture must leave the saved marker unsynchronized" + ); + let after = eval + .window_display_snapshot_freshness(frame, window, buffer) + .unwrap(); + assert_ne!( + before, after, + "live selected-window point must invalidate exact freshness" + ); + assert!(!before.same_query_row_content(&after)); + let other_after = eval + .window_display_snapshot_freshness(other_frame, other_window, buffer) + .unwrap(); + assert!( + other_before.same_query_row_content(&other_after), + "a different frame displaying the same buffer must keep its own saved point" + ); + assert!( + before.same_position_rows_ignoring_point(&after), + "literal chrome without numbering still permits point-only row reuse" + ); + } +} diff --git a/docs/research/point-dependent-position-query-geometry.md b/docs/research/point-dependent-position-query-geometry.md new file mode 100644 index 0000000000..ccd41bf6b2 --- /dev/null +++ b/docs/research/point-dependent-position-query-geometry.md @@ -0,0 +1,110 @@ +# Point-dependent geometry after PR #616 + +PR #616 deferred two geometry corrections: point-dependent window chrome and +absolute line-number gutters with a different current-line face. Both affect +`posn-at-point` / `pos-visible-in-window-p` before redisplay. + +## GNU evidence + +Local reference: `/home/exec/Projects/github.com/emacs-mirror/emacs`. + +- `src/keyboard.c`, `Fposn_at_point`, delegates visibility to + `Fpos_visible_in_window_p`. +- `src/xdisp.c`, `pos_visible_p`, recomputes mode/header/tab-line heights before + starting the body iterator. A private-display probe growing the header from + height 1.0 to 4.0 after moving point returned `(67 t nil)`: the same last-row + target became invisible without redisplay. +- `src/xdisp.c`, `try_cursor_movement`, rejects the shortcut when line-number + faces differ, including absolute numbering. A private GNU probe with a 2.0 + current-line face changed position coordinates from `(68, 38)` to `(32, 20)` + after moving point. +- `src/window.c`, `window_point`, reads the selected window's live buffer point + and uses the stored marker for other windows. This remains true when the + current buffer differs from the selected window's buffer. +- `pos_visible_p` restores the saved chrome heights after its temporary walk; + the observation must not acknowledge a new redisplay partition. + +GNU documents that `:eval` can produce propertized chrome strings in +[Properties in Mode](https://www.gnu.org/software/emacs/manual/html_node/elisp/Properties-in-Mode.html). +The format value's identity alone therefore cannot prove that its measured +height remains valid after point changes. + +## Reuse boundary + +The canonical freshness token owns the permission to ignore point, rather than +leaving each consumer to normalize point independently. A closed enum records +whether native inputs certify independence or require measurement. Executable, +symbolic, conditional, propertized and dynamically substituted chrome formats +need the existing targeted `Position` query. Literal property-free chrome +retains the shortcut. This is conservative: proving additional formats safe +belongs at the same boundary, with geometry evidence. + +The existing query adapter remains responsible for evaluation, row production, +clipping and visibility. The token classifier invokes no Lisp and does not add +a second chrome evaluation or a second partial position-query implementation. + + +Absolute numbering is deliberately not treated as a metric proof. Active line +numbering uses canonical measurement until the producer can certify the +current-line face's effective geometry, including inheritance and remapping. +This trades a broader fast path for correct queries; default and buffer-local +line-number settings are resolved by the canonical native variable lookup. +The old caller-specific numbering predicate is removed. + +## Live pipeline corrections + +The first real GUI regression failed despite the synthetic adapter tests: +moving point left the freshness token's selected-window marker unchanged. +Admission therefore accepted an exact match before checking point independence. +The token now projects the selected frame/window's live buffer point without +mutating the marker; nonselected windows retain their own stored points. + +Synchronizing that marker in a diagnostic probe fixed the line-number case but +still left all three chrome cases wrong. The canonical query adapter treated +position visibility like `window-end` and preserved the accepted chrome heights. +The publication policy now distinguishes those operations with enum variants: +`window-end` preserves its accepted partition, while an initial position query +measures live chrome using the existing renderer. Neither query publishes +start/end markers or replaces the accepted redisplay snapshot. + +When the measured chrome heights require a physical retry, a one-use enum phase +passes only native dimensions to the immediately following attempt. It avoids +evaluating observable `:eval` forms twice. Logical, source or face retries must +measure again. No Lisp value is retained by this phase. + +A viewport query cannot certify a later position query while chrome exists: +it may have preserved obsolete heights under otherwise current input tokens. +The chrome-free viewport shortcut remains available. Position requests with +active chrome are not memoized, including when a final height retry emits no +chrome strings, because arbitrary scalar Lisp reads are not collection-read +certificates. + +Temporary chrome evaluation also writes native thread-local generation, +fingerprint, evaluation-count and column-consumption records. Thread-confined +RAII scopes isolate and restore those records so an unpublished query cannot +alter the enclosing redisplay's bookkeeping. + +## Regression coverage + +Core tests cover selected live point versus stored markers, dynamic chrome and +absolute current-line number metrics. Real layout-engine tests cover a preceding +`window-end` observation, one evaluation per physical height convergence, +remeasurement on later requests and restoration of enclosing chrome records. +The source-horizon fixture explicitly disables chrome to continue testing only +bounded body acquisition and prefix-cache coverage. + +The GUI regression runs GNU and Neomacs on private displays and moves point +without redisplay. Each mode/header/tab-line case checks that a taller live row +hides the old last body target. The current-line-number case checks that moving +the tall current-line face lowers the target's Y coordinate. Font-dependent +absolute pixel coordinates need not match between the two executables. + +Final verification used `cargo nextest`: 141 core tests, 353 layout-engine tests, +two oracle tests, one TUI comparison and three real GUI tests passed. The full +release fresh-build also regenerated the matching dump and Lisp bytecode. +Both GUI executables returned `[true, false]` for each chrome case; current-line +Y changed from 38 to 20 in GNU and from 35 to 18 in Neomacs. Startup surface +readback was visually checked. Timer-driven navigation ran with hardware +`quality_mode=Full`, reached line 261 and recorded 317 scene commits, 494 root +glyph passes and 1,099 composite-only frames at the final periodic sample. +Every GUI process used a private display.