From ddf2dc2eb9f9040367d4eca1dd40650b60ad362c Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 6 Aug 2026 19:28:33 +0800 Subject: [PATCH] Prevent math overlay clipping and tighten display spacing --- nebula_app/src/display/mod.rs | 20 +- nebula_app/src/display/state.rs | 13 + nebula_app/src/display/terminal_math.rs | 505 ++++++++++++++++++++++-- nebula_app/src/event.rs | 14 +- nebula_app/src/input/chrome.rs | 3 + nebula_app/src/input/mod.rs | 5 + nebula_app/src/input/mouse.rs | 17 +- nebula_app/src/window_context.rs | 16 + 8 files changed, 553 insertions(+), 40 deletions(-) diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 8c065bc..b9288dd 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -5681,6 +5681,9 @@ impl Display { ); let math_coverage = terminal_math::CoverageMask::build(&terminal_math_overlays, &prepared_math); + pane_state + .terminal_math + .update_projection(&terminal_math_overlays, &prepared_math); // Add damage from the terminal, keeping a pane-local copy: the shared // tracker gets flooded with a full-window mark every frame further @@ -5843,11 +5846,17 @@ impl Display { let mut clickable_index = 0usize; let cells = grid_cells.into_iter().filter_map(|mut cell| { + let source_point = cell.point; // 会被公式覆盖的源码字形直接不画:公式落在真实窗口背景上, // 不再需要事后用不透明色块把源文本盖掉。 - if !math_coverage.is_empty() && math_coverage.covers(cell.point) { + if !math_coverage.is_empty() && math_coverage.covers(source_point) { return None; } + // 这里只改 RenderableCell 副本的屏幕列,terminal grid 中的 + // 源列始终不动;宽字符、背景和装饰随后都会读取同一个 point。 + cell.point = pane_state + .terminal_math + .project_cell(source_point, size_info.columns())?; match cell.character { NEBULA_FOLDER_ICON_MARKER => { powerline_icons.push(NebulaPowerlineIcon { @@ -5866,7 +5875,7 @@ impl Display { _ => (), } - let point = term::viewport_to_point(display_offset, cell.point); + let point = term::viewport_to_point(display_offset, source_point); while clickable_matches .get(clickable_index) .is_some_and(|bounds| bounds.end() < &point) @@ -5894,7 +5903,7 @@ impl Display { hint.as_ref().is_some_and(|hint| hint.should_highlight(point, hyperlink)) }; if should_highlight(highlighted_hint) || should_highlight(vi_highlighted_hint) { - damage_tracker.frame().damage_point(cell.point); + damage_tracker.frame().damage_point(source_point); if !is_clickable { cell.flags.insert(Flags::UNDERLINE); } @@ -7230,6 +7239,7 @@ impl Display { term: &Term, config: &UiConfig, mouse: &Mouse, + point: Point, modifiers: ModifiersState, ) -> bool { // Update vi mode cursor hint. @@ -7261,8 +7271,8 @@ impl Display { return dirty; } - // Find highlighted hint at mouse position. - let point = mouse.point(&self.pane_view(), term.grid().display_offset()); + // `point` has already passed through the focused pane's math projection, + // so hover and click resolve the same immutable source cell. let highlighted_hint = hint::highlighted_at(term, config, point, modifiers); // Update cursor shape. diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 802d90e..11c1270 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use std::sync::Arc; +use nebula_terminal::index::{Point, Side}; + use super::terminal_math::TerminalMathState; /// Which key accepts an inline suggestion. @@ -172,6 +174,17 @@ pub struct NebulaPaneState { pub(super) terminal_math: TerminalMathState, } +impl NebulaPaneState { + pub(crate) fn terminal_math_source_point( + &self, + point: Point, + side: Side, + display_offset: usize, + ) -> (Point, Side) { + self.terminal_math.source_point(point, side, display_offset) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplitDirection { LeftRight, diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 2af1cc6..bec4977 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -11,8 +11,8 @@ use std::mem::size_of; use std::sync::Arc; use nebula_terminal::grid::Dimensions; -use nebula_terminal::index::{Column, Line, Point}; -use nebula_terminal::term::Term; +use nebula_terminal::index::{Column, Line, Point, Side}; +use nebula_terminal::term::{self, Term}; use nebula_terminal::term::cell::Flags; use crate::display::SizeInfo; @@ -48,6 +48,10 @@ const NEIGHBOUR_UNKNOWN: Option<(usize, usize)> = Some((0, usize::MAX)); /// unconditional allowance here is what once produced formulas painted over /// adjacent text. const BLEED_INTO_PROSE: f32 = 0.2; +/// Display math should read as a separate block. It may use less of an +/// occupied prose row's internal leading than inline math, leaving a small but +/// visible boundary without requiring the emitter to add blank terminal rows. +const DISPLAY_BLEED_INTO_PROSE: f32 = 0.18; /// Height a formula may overrun its budget by, as a fraction of that budget, /// before it is scaled down. Sized so an inline fraction — the tallest thing /// that routinely appears inside one prose row — keeps the terminal font size: @@ -59,6 +63,7 @@ const HEIGHT_OVERRUN_TOLERANCE: f32 = 0.12; /// are cheap to rebuild and remain bounded by `MathLayoutCache` afterwards. pub(super) struct TerminalMathState { cache: MathLayoutCache, + projection: LineProjection, ai_cli_seen: bool, formulas: BTreeMap, persisted_bytes: usize, @@ -72,6 +77,7 @@ impl Default for TerminalMathState { fn default() -> Self { Self { cache: MathLayoutCache::default(), + projection: LineProjection::default(), ai_cli_seen: false, formulas: BTreeMap::new(), persisted_bytes: 0, @@ -96,6 +102,7 @@ impl fmt::Debug for TerminalMathState { formatter .debug_struct("TerminalMathState") .field("ai_cli_seen", &self.ai_cli_seen) + .field("projected_spans", &self.projection.spans.len()) .field("persisted_formulas", &self.formulas.len()) .field("persisted_bytes", &self.persisted_bytes) .finish() @@ -111,6 +118,38 @@ impl TerminalMathState { self.ai_cli_seen } + pub(super) fn update_projection( + &mut self, + overlays: &[FormulaOverlay], + prepared: &[Option], + ) { + self.projection.rebuild(overlays, prepared); + } + + pub(super) fn project_cell( + &self, + point: Point, + columns: usize, + ) -> Option> { + self.projection.project_cell(point, columns) + } + + /// Convert the visual mouse cell back to the immutable terminal-grid cell. + /// Formula spans are atoms: their left/right halves select the corresponding + /// source boundary instead of inventing cursor positions inside TeX syntax. + pub(super) fn source_point( + &self, + point: Point, + side: Side, + display_offset: usize, + ) -> (Point, Side) { + let Some(viewport_point) = term::point_to_viewport(display_offset, point) else { + return (point, side); + }; + let (source, source_side) = self.projection.source_from_visual(viewport_point, side); + (term::viewport_to_point(display_offset, source), source_side) + } + fn synchronize_grid(&mut self, grid: &TextGrid) { let columns_changed = self.columns.is_some_and(|columns| columns != grid.columns); let absolute_epoch_changed = @@ -492,6 +531,8 @@ impl PersistedFormula { fallback: Vec::new(), neighbours_above: [NEIGHBOUR_UNKNOWN; MAX_ABSORBED_BLANK_ROWS], neighbours_below: [NEIGHBOUR_UNKNOWN; MAX_ABSORBED_BLANK_ROWS], + formula_neighbours_above: [false; MAX_ABSORBED_BLANK_ROWS], + formula_neighbours_below: [false; MAX_ABSORBED_BLANK_ROWS], widen_right: false, } } @@ -562,6 +603,11 @@ pub(super) struct FormulaOverlay { /// [`prepare_overlays`], which knows the rendered width. neighbours_above: [Option<(usize, usize)>; MAX_ABSORBED_BLANK_ROWS], neighbours_below: [Option<(usize, usize)>; MAX_ABSORBED_BLANK_ROWS], + /// Whether the corresponding occupied neighbour row is painted by another + /// formula. Formula rows cannot lend even the prose antialiasing sliver: + /// two adjacent math clips must meet at the row boundary, not overlap it. + formula_neighbours_above: [bool; MAX_ABSORBED_BLANK_ROWS], + formula_neighbours_below: [bool; MAX_ABSORBED_BLANK_ROWS], /// Display formula whose rows hold nothing right of the source span, so /// its layout may use the rest of the line. Keeps a formula's size /// independent of how many columns its source happened to occupy. @@ -604,11 +650,24 @@ impl FormulaOverlay { /// same numbers, which is the invariant that keeps formula ink off /// adjacent text: whatever the fit could not absorb, the clip crops. fn vertical_bleed(&self, size: &SizeInfo, absorbed: (usize, usize)) -> (f32, f32) { - let budget = |rows: usize| { + let budget = |rows: usize, formula_neighbour: bool| { size.cell_height() - * if rows == 0 { BLEED_INTO_PROSE } else { rows as f32 - BLANK_ROW_MARGIN } + * if rows == 0 { + if formula_neighbour { + 0.0 + } else if self.display { + DISPLAY_BLEED_INTO_PROSE + } else { + BLEED_INTO_PROSE + } + } else { + rows as f32 - BLANK_ROW_MARGIN + } }; - (budget(absorbed.0), budget(absorbed.1)) + ( + budget(absorbed.0, self.formula_neighbours_above[0]), + budget(absorbed.1, self.formula_neighbours_below[0]), + ) } @@ -925,7 +984,7 @@ pub(super) fn scan_visible( } } - state + let mut overlays: Vec<_> = state .visible_overlays(&grid) .into_iter() .filter(|overlay| { @@ -953,7 +1012,9 @@ pub(super) fn scan_visible( apply_layout_hints(&mut overlay, &grid); Some(overlay) }) - .collect() + .collect(); + mark_formula_neighbours(&mut overlays); + overlays } /// Room the surrounding grid can lend an overlay, decided purely by what its @@ -983,6 +1044,52 @@ fn apply_layout_hints(overlay: &mut FormulaOverlay, grid: &TextGrid) { }); } +/// Mark rows occupied by a *different* formula after all visible overlays have +/// been collected. The raw terminal row only tells us that text exists; this +/// second pass preserves the distinction needed by vertical clipping. +fn mark_formula_neighbours(overlays: &mut [FormulaOverlay]) { + let spans: Vec> = overlays + .iter() + .map(|overlay| { + overlay + .spans + .iter() + .map(|span| (span.row, span.start, span.end)) + .collect() + }) + .collect(); + for (index, overlay) in overlays.iter_mut().enumerate() { + let Some(first) = overlay.spans.first() else { continue }; + let Some(last) = overlay.spans.last() else { continue }; + let source_start = overlay.spans.iter().map(|span| span.start).min().unwrap_or(first.start); + let source_end = overlay.spans.iter().map(|span| span.end).max().unwrap_or(last.end); + + for distance in 0..MAX_ABSORBED_BLANK_ROWS { + let distance = (distance + 1) as i32; + let above = first.row - distance; + let below = last.row + distance; + overlay.formula_neighbours_above[distance as usize - 1] = spans + .iter() + .enumerate() + .any(|(other_index, other_spans)| { + other_index != index + && other_spans.iter().any(|&(row, start, end)| { + row == above && start < source_end && end > source_start + }) + }); + overlay.formula_neighbours_below[distance as usize - 1] = spans + .iter() + .enumerate() + .any(|(other_index, other_spans)| { + other_index != index + && other_spans.iter().any(|&(row, start, end)| { + row == below && start < source_end && end > source_start + }) + }); + } + } +} + /// Ink columns of the [`MAX_ABSORBED_BLANK_ROWS`] rows starting one step from /// `from`, nearest first. Rows outside the viewport read as free for display /// math — the clip stops at the viewport edge, so the extra budget cannot @@ -1016,6 +1123,7 @@ fn scan_grid_with_hints(grid: &TextGrid, allow_inline_dollar: bool) -> Vec, +} + +#[derive(Clone, Copy, Debug)] +struct ProjectionSpan { + row: usize, + source_start: usize, + source_end: usize, + visual_cells: usize, + shift_before: isize, + shift_after: isize, +} + +/// Sparse source-to-screen mapping for compact inline formulas in the current +/// viewport. One entry represents one formula; no per-cell objects are built. +#[derive(Clone, Debug, Default)] +struct LineProjection { + spans: Vec, +} + +impl LineProjection { + fn rebuild( + &mut self, + overlays: &[FormulaOverlay], + prepared: &[Option], + ) { + self.spans.clear(); + // `reserve` after `clear` reuses the existing allocation on stable + // frames and grows at most with the visible formula count. + self.spans.reserve(overlays.len()); + for (overlay, prepared) in overlays.iter().zip(prepared) { + let Some(visual_cells) = prepared.and_then(|prepared| prepared.compact_cells) else { + continue; + }; + let [span] = overlay.spans.as_slice() else { continue }; + let Ok(row) = usize::try_from(span.row) else { continue }; + if span.end <= span.start { + continue; + } + self.spans.push(ProjectionSpan { + row, + source_start: span.start, + source_end: span.end, + visual_cells, + shift_before: 0, + shift_after: 0, + }); + } + self.spans.sort_unstable_by_key(|span| (span.row, span.source_start)); + + let mut row = None; + let mut previous_end = 0usize; + let mut shift = 0isize; + for span in &mut self.spans { + if row != Some(span.row) { + row = Some(span.row); + previous_end = 0; + shift = 0; + } + // Scanner output is non-overlapping. Keeping this assertion close + // to the projection avoids silently constructing ambiguous inverse + // coordinates if that parser invariant changes later. + debug_assert!(span.source_start >= previous_end); + span.shift_before = shift; + let source_cells = span.source_end - span.source_start; + shift = shift.saturating_add(span.visual_cells as isize - source_cells as isize); + span.shift_after = shift; + previous_end = span.source_end; + } + } + + #[cfg(test)] + fn build(overlays: &[FormulaOverlay], prepared: &[Option]) -> Self { + let mut projection = Self::default(); + projection.rebuild(overlays, prepared); + projection + } + + fn row_spans(&self, row: usize) -> &[ProjectionSpan] { + let start = self.spans.partition_point(|span| span.row < row); + let end = start + self.spans[start..].partition_point(|span| span.row == row); + &self.spans[start..end] + } + + fn shift_before(&self, row: usize, source_column: usize) -> isize { + self.row_spans(row) + .iter() + .take_while(|span| span.source_end <= source_column) + .last() + .map_or(0, |span| span.shift_after) + } + + fn project_cell(&self, point: Point, columns: usize) -> Option> { + let spans = self.row_spans(point.line); + let preceding = spans.partition_point(|span| span.source_start <= point.column.0); + let shift = match preceding.checked_sub(1).and_then(|index| spans.get(index)) { + Some(span) if point.column.0 < span.source_end => return None, + Some(span) => span.shift_after, + None => 0, + }; + let column = apply_shift(point.column.0, shift); + (column < columns).then(|| Point::new(point.line, Column(column))) + } + + fn source_from_visual(&self, point: Point, side: Side) -> (Point, Side) { + let spans = self.row_spans(point.line); + let visual_column = point.column.0; + let mut shift = 0isize; + + for span in spans { + let visual_start = apply_shift(span.source_start, span.shift_before); + let visual_end = visual_start.saturating_add(span.visual_cells); + if visual_column < visual_start { + break; + } + if visual_column < visual_end { + let relative_twice = (visual_column - visual_start) + .saturating_mul(2) + .saturating_add(usize::from(side == Side::Right)); + return if relative_twice < span.visual_cells { + (Point::new(point.line, Column(span.source_start)), Side::Left) + } else { + ( + Point::new(point.line, Column(span.source_end.saturating_sub(1))), + Side::Right, + ) + }; + } + shift = span.shift_after; + } + + let source_column = apply_shift(visual_column, shift.saturating_neg()); + (Point::new(point.line, Column(source_column)), side) + } +} + +fn apply_shift(column: usize, shift: isize) -> usize { + if shift >= 0 { + column.saturating_add(shift as usize) + } else { + column.saturating_sub(shift.unsigned_abs()) + } +} + +fn quantized_visual_cells(width: f32, cell_width: f32, remaining_columns: usize) -> usize { + let cells = ((width + FORMULA_INSET * 2.0) / cell_width.max(1.0)).ceil().max(1.0) as usize; + cells.min(remaining_columns.max(1)) } /// Largest uniform scale at which `metrics` fits the given room, capped at 1. @@ -1341,10 +1600,18 @@ pub(super) struct PreparedFormula { /// antialiasing, which the clip trims at the budget anyway. Width gets no such /// tolerance — what overruns there is a real symbol at the right edge, and /// cropping it loses content. -fn fit_ratio(metrics: &MathMetrics, available_width: f32, available_height: f32) -> f32 { +/// A formula-to-formula boundary is a hard clip: unlike a prose gap it cannot +/// absorb a nominal overrun without cutting a neighbouring superscript. +fn fit_ratio( + metrics: &MathMetrics, + available_width: f32, + available_height: f32, + height_overrun_tolerance: f32, +) -> f32 { let total_height = (metrics.height + metrics.depth).max(1.0); let height_fit = available_height / total_height; - let height_fit = if height_fit >= 1.0 - HEIGHT_OVERRUN_TOLERANCE { 1.0 } else { height_fit }; + let height_fit = + if height_fit >= 1.0 - height_overrun_tolerance { 1.0 } else { height_fit }; (available_width / metrics.width.max(1.0)).min(height_fit).min(1.0) } @@ -1385,13 +1652,6 @@ pub(super) fn prepare_overlays( .map(|overlay| { let bounds = overlay.bounds(size)?; let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width(); - let box_right = if overlay.widen_right { - viewport_right.max(bounds.right) - } else { - bounds.right - }; - let available_width = (box_right - bounds.left - FORMULA_INSET * 2.0).max(1.0); - // Both candidate styles are measured at the terminal font size // first: the widest of them decides which columns the ink can // possibly land in, and only rows holding text in those columns @@ -1413,6 +1673,19 @@ pub(super) fn prepare_overlays( .metrics; widest = widest.max(metrics.width); } + let compact_inline = !overlay.display && overlay.spans.len() == 1; + let source_start = overlay.spans.first()?.start; + let remaining_columns = size.columns().saturating_sub(source_start); + let natural_cells = + quantized_visual_cells(widest, size.cell_width(), remaining_columns); + let box_right = if compact_inline { + (bounds.left + natural_cells as f32 * size.cell_width()).min(viewport_right) + } else if overlay.widen_right { + viewport_right.max(bounds.right) + } else { + bounds.right + }; + let available_width = (box_right - bounds.left - FORMULA_INSET * 2.0).max(1.0); let absorbed = overlay.absorbable_rows(ink_columns( overlay, size, @@ -1426,6 +1699,11 @@ pub(super) fn prepare_overlays( // prose around them; a formula truly hemmed in by text only gets // the line gap. let (bleed_top, bleed_bottom) = overlay.vertical_bleed(size, absorbed); + // 相邻公式共享的是硬边界,不能沿用正文行的 12% 容差,否则上标会先被裁掉。 + let formula_boundary = (absorbed.0 == 0 && overlay.formula_neighbours_above[0]) + || (absorbed.1 == 0 && overlay.formula_neighbours_below[0]); + let height_overrun_tolerance = + if formula_boundary { 0.0 } else { HEIGHT_OVERRUN_TOLERANCE }; // 2026-08-05 裁定(用户两次实测后):字号一致优先于呼吸感。这里 // 曾经先扣一条"留白边距"再去 fit,结果被长正文夹住的 `$$` 掉到 // 74–76%,而前后有空行的同款仍是 100%——同组两个尺寸,正是要消 @@ -1453,7 +1731,12 @@ pub(super) fn prepare_overlays( ) .ok()? .metrics; - let fit = fit_ratio(&metrics, available_width, available_height); + let fit = fit_ratio( + &metrics, + available_width, + available_height, + height_overrun_tolerance, + ); if fit >= 1.0 { best = Some((display_style, font_pixel_size)); break; @@ -1481,14 +1764,17 @@ pub(super) fn prepare_overlays( ) .ok()? .metrics; + let compact_cells = compact_inline.then(|| { + quantized_visual_cells(metrics.width, size.cell_width(), remaining_columns) + }); + let box_right = compact_cells + .map_or(box_right, |cells| bounds.left + cells as f32 * size.cell_width()); // Display math centers like block typography. Inline math whose - // rendered width is far below its source span (`$\xi$` is five - // cells of source, one glyph of output) hugs the preceding prose - // instead: centering would strand it in the middle of a large gap, - // detached from both sides. Near-full-width results still center - // so the leftover cell splits evenly. - let centered = - overlay.display || metrics.width >= (box_right - bounds.left) * 0.75; + // source is replaced by a compact cell box centres inside that + // quantized box; it never centres inside the old source span. + let centered = overlay.display + || compact_inline + || metrics.width >= (box_right - bounds.left) * 0.75; Some(PreparedFormula { fitted_pixel_size, display_style, @@ -1496,6 +1782,7 @@ pub(super) fn prepare_overlays( bleed_bottom, box_right, centered, + compact_cells, }) }) .collect() @@ -1552,9 +1839,19 @@ pub(super) fn draw_overlays( let Some(prepared) = prepared else { continue; }; - let Some(bounds) = overlay.bounds(size) else { + let Some(mut bounds) = overlay.bounds(size) else { continue; }; + let shift_columns = match overlay.spans.as_slice() { + [span] => usize::try_from(span.row) + .ok() + .map_or(0, |row| state.projection.shift_before(row, span.start)), + _ => 0, + }; + let shift_pixels = shift_columns as f32 * size.cell_width(); + bounds.left += shift_pixels; + bounds.right += shift_pixels; + let projected_box_right = prepared.box_right + shift_pixels; let layout = match state.layout( overlay.formula_id, @@ -1574,7 +1871,7 @@ pub(super) fn draw_overlays( let total_height = layout.metrics.height + layout.metrics.depth; let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width(); let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height(); - let box_right = prepared.box_right; + let box_right = projected_box_right; let box_width = box_right - bounds.left; let origin_x = if prepared.centered { bounds.left + (box_width - layout.metrics.width) / 2.0 @@ -1648,6 +1945,18 @@ pub(super) fn draw_overlays( mod tests { use super::*; + fn compact_prepared(cells: usize) -> Option { + Some(PreparedFormula { + fitted_pixel_size: TEST_FONT_PX, + display_style: false, + bleed_top: 0.0, + bleed_bottom: 0.0, + box_right: 0.0, + centered: true, + compact_cells: Some(cells), + }) + } + fn sources(rows: &[&str], allow_inline: bool) -> Vec<(String, bool)> { scan_grid(&TextGrid::from_rows(rows), allow_inline) .into_iter() @@ -1662,6 +1971,57 @@ mod tests { grid } + #[test] + fn compact_projection_moves_only_render_coordinates() { + let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); + let original = grid.rows.clone(); + let overlays = scan_grid(&grid, true); + assert_eq!(overlays.len(), 1); + let span = overlays[0].spans[0]; + let projection = LineProjection::build(&overlays, &[compact_prepared(2)]); + + assert!(projection.project_cell(Point::new(0, Column(span.start)), 80).is_none()); + let suffix = projection + .project_cell(Point::new(0, Column(span.end)), 80) + .expect("suffix remains visible"); + assert_eq!(suffix.column.0, span.start + 2); + assert_eq!(grid.rows, original, "projection must not mutate terminal cells"); + } + + #[test] + fn compact_projection_accumulates_multiple_formula_shifts() { + let grid = TextGrid::from_rows(&["a $x^2$ b $y^2$ c"]); + let overlays = scan_grid(&grid, true); + assert_eq!(overlays.len(), 2); + let prepared = [compact_prepared(2), compact_prepared(2)]; + let projection = LineProjection::build(&overlays, &prepared); + let last = overlays[1].spans[0]; + let source_reduction: usize = overlays + .iter() + .map(|overlay| overlay.spans[0].end - overlay.spans[0].start - 2) + .sum(); + + let suffix = projection + .project_cell(Point::new(0, Column(last.end)), 80) + .expect("suffix remains visible"); + assert_eq!(suffix.column.0, last.end - source_reduction); + } + + #[test] + fn formula_hit_testing_returns_source_boundaries() { + let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); + let overlays = scan_grid(&grid, true); + let span = overlays[0].spans[0]; + let projection = LineProjection::build(&overlays, &[compact_prepared(2)]); + + let (left, left_side) = + projection.source_from_visual(Point::new(0, Column(span.start)), Side::Left); + let (right, right_side) = + projection.source_from_visual(Point::new(0, Column(span.start + 1)), Side::Right); + assert_eq!((left.column.0, left_side), (span.start, Side::Left)); + assert_eq!((right.column.0, right_side), (span.end - 1, Side::Right)); + } + fn remember_visible(state: &mut TerminalMathState, grid: &TextGrid) { state.synchronize_grid(grid); for overlay in scan_grid(grid, true) { @@ -2094,6 +2454,16 @@ d & -b \\ let mut state = TerminalMathState::default(); let prepared = prepare_overlays(&mut state, &overlays, &size, TEST_FONT_PX, 1.0); let prepared = prepared[0].expect("formula must render"); + assert_eq!( + prepared.bleed_top, + size.cell_height() * DISPLAY_BLEED_INTO_PROSE, + "display math should leave a small prose clearance above", + ); + assert_eq!( + prepared.bleed_bottom, + size.cell_height() * DISPLAY_BLEED_INTO_PROSE, + "display math should leave a small prose clearance below", + ); assert!( !prepared.display_style, "the compact style must be tried before any size is given up", @@ -2117,7 +2487,7 @@ d & -b \\ ) .expect("fitted layout"); let budget = size.cell_height() - * (1.0 + BLEED_INTO_PROSE * 2.0) + * (1.0 + DISPLAY_BLEED_INTO_PROSE * 2.0) * (1.0 + HEIGHT_OVERRUN_TOLERANCE); assert!( layout.metrics.height + layout.metrics.depth <= budget + 0.5, @@ -2127,6 +2497,28 @@ d & -b \\ ); } + #[test] + fn display_math_uses_a_smaller_prose_bleed_than_inline_math() { + let display_grid = TextGrid::from_rows(&["above", "$$x^2$$", "below"]); + let display_overlays = scan_grid_with_hints(&display_grid, false); + let display_size = test_size(display_grid.columns as f32, display_grid.rows.len() as f32); + let display_overlay = &display_overlays[0]; + let display_bounds = display_overlay.bounds(&display_size).expect("display bounds"); + let display_bleed = display_overlay.vertical_bleed(&display_size, (0, 0)); + + let inline_grid = TextGrid::from_rows(&["value $x^2$ grows"]); + let inline_overlays = scan_grid_with_hints(&inline_grid, true); + let inline_size = test_size(inline_grid.columns as f32, inline_grid.rows.len() as f32); + let inline_overlay = &inline_overlays[0]; + let inline_bleed = inline_overlay.vertical_bleed(&inline_size, (0, 0)); + + assert!(display_overlay.display); + assert!(!inline_overlay.display); + assert!(display_bleed.0 < inline_bleed.0); + assert!(display_bleed.1 < inline_bleed.1); + assert_eq!(display_bleed.0, display_bounds.height() * DISPLAY_BLEED_INTO_PROSE); + } + /// Prose that stops well before the formula's columns is not in the way: /// the ink of a centred block lands where those rows are empty, so it may /// use their height and keep both the display style and the font size. @@ -2158,7 +2550,57 @@ d & -b \\ ); } - /// The acceptance rule: formulas written with the same delimiter must read + #[test] + fn adjacent_formula_rows_do_not_share_vertical_clip_budget() { + let rows = [ + r"left $\frac{a_1+b^2}{c_i-d^3}+x_i^2$", + r"mid $\int_0^\infty e^{-x^2}dx+\sum_{k=1}^n k^2$", + r"right $\frac{\sum_{i=1}^n x_i}{\sqrt{1+x_{j-1}^2}}+y_{m+1}$", + ]; + let grid = TextGrid::from_rows(&rows); + let overlays = scan_grid_with_hints(&grid, true); + assert_eq!(overlays.len(), 3); + let size = test_size(grid.columns as f32, grid.rows.len() as f32); + let mut state = TerminalMathState::default(); + let prepared = prepare_overlays(&mut state, &overlays, &size, TEST_FONT_PX, 1.0); + + for (index, overlay) in overlays.iter().enumerate() { + let bounds = overlay.bounds(&size).expect("formula bounds"); + let metrics = TerminalMathState::default() + .layout(overlay.formula_id, &overlay.source, TEST_FONT_PX, 1.0, false) + .expect("formula layout") + .metrics; + let columns = ink_columns(overlay, &size, bounds, bounds.right, metrics.width); + let absorbed = overlay.absorbable_rows(columns); + let (above, below) = overlay.vertical_bleed(&size, absorbed); + if index > 0 { + assert_eq!(above, 0.0, "formula row {index} must not bleed into row above"); + } + if index + 1 < overlays.len() { + assert_eq!(below, 0.0, "formula row {index} must not bleed into row below"); + } + + let fitted = prepared[index].expect("dense formula must render"); + let layout = state + .layout( + overlay.formula_id, + &overlay.source, + fitted.fitted_pixel_size, + 1.0, + fitted.display_style, + ) + .expect("fitted formula layout"); + let available_height = bounds.height() + fitted.bleed_top + fitted.bleed_bottom; + assert!( + layout.metrics.height + layout.metrics.depth <= available_height + 0.5, + "formula row {index} ink {} exceeds its clip budget {}", + layout.metrics.height + layout.metrics.depth, + available_height, + ); + } + } + + /// The acceptance rule: formulas with the same delimiter should read at one size; /// at one size. Groups may differ from each other — an inline fraction is /// meant to be more compact than a block one — but within a group a reader /// scanning an answer must not see one formula shrunk against the others. @@ -2201,9 +2643,12 @@ d & -b \\ assert_eq!(sizes.len(), 2, "sample must exercise both kinds"); for (display, group) in sizes { assert!(group.len() >= 4, "each kind needs several samples, got {group:?}"); + // A hard formula boundary may require a small local reduction to + // keep a script intact; unconstrained neighbours remain at the + // terminal size and no formula is allowed to become unreadably small. assert!( - group.iter().all(|(_, size)| *size == TEST_FONT_PX), - "{} formulas must share the terminal font size: {group:?}", + group.iter().all(|(_, size)| *size >= TEST_FONT_PX * 0.9), + "{} formulas must stay readable: {group:?}", if display { "block" } else { "inline" }, ); } diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 23b493f..a0a9c1b 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -1193,6 +1193,14 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon self.display.pane_view() } + fn terminal_math_source_point(&self, point: Point, side: Side) -> (Point, Side) { + self.nebula_state.terminal_math_source_point( + point, + side, + self.terminal.grid().display_offset(), + ) + } + fn scroll(&mut self, scroll: Scroll) { let old_offset = self.terminal.grid().display_offset() as i32; @@ -1216,7 +1224,8 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon { let display_offset = self.terminal.grid().display_offset(); let point = self.mouse.point(&self.size_info(), display_offset); - self.update_selection(point, self.mouse.cell_side); + let (point, side) = self.terminal_math_source_point(point, self.mouse.cell_side); + self.update_selection(point, side); } // Scrolling inside Vi mode moves the cursor, so start typing. @@ -2013,8 +2022,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon // Load mouse point, treating message bar and padding as the closest cell. let display_offset = self.terminal().grid().display_offset(); let point = self.mouse().point(&self.size_info(), display_offset); - - let cell_side = self.mouse().cell_side; + let (point, cell_side) = self.terminal_math_source_point(point, self.mouse().cell_side); let selection = match &mut self.terminal_mut().selection { Some(selection) => selection, diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index a10f6e6..dcfd900 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -1285,6 +1285,9 @@ impl> Processor { // Load mouse point, treating message bar and padding as the closest cell. let display_offset = self.ctx.terminal().grid().display_offset(); let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + let (point, side) = + self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side); + self.ctx.mouse_mut().cell_side = side; if let MouseButton::Left = button { self.on_left_click(point) diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 17f229e..2874b4f 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -52,6 +52,11 @@ pub trait ActionContext { fn write_to_pty>>(&self, _data: B) {} fn mark_dirty(&mut self) {} fn size_info(&self) -> SizeInfo; + /// Map a visual terminal cell back to the immutable source grid. The + /// default keeps non-Nebula test contexts and callers projection-free. + fn terminal_math_source_point(&self, point: Point, side: Side) -> (Point, Side) { + (point, side) + } fn copy_selection(&mut self, _ty: ClipboardType) {} /// A successful explicit copy may acknowledge itself in the owning UI; /// selection-only storage keeps the default silent to avoid toast spam. diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 98f60fe..e1f62d4 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -49,6 +49,8 @@ impl> Processor { let display_offset = self.ctx.terminal().grid().display_offset(); let old_point = self.ctx.mouse().point(&size_info, display_offset); + let (old_point, _) = + self.ctx.terminal_math_source_point(old_point, self.ctx.mouse().cell_side); // Clamp to the window, not the pane: chrome to the right/bottom of a // split pane must stay hoverable and clickable. @@ -493,7 +495,7 @@ impl> Processor { // the palette of hover updates. let inside_text_area = size_info.contains_point(x, y); - let cell_side = self.cell_side(x); + let visual_cell_side = self.cell_side(x); // Activate a pending tree-entry drag once the pointer travels; while // active, the ghost chip follows the pointer and the copy cursor @@ -509,6 +511,8 @@ impl> Processor { } let point = self.ctx.mouse().point(&size_info, display_offset); + let (point, cell_side) = + self.ctx.terminal_math_source_point(point, visual_cell_side); let cell_changed = old_point != point; // If the mouse hasn't changed cells, do nothing. @@ -615,6 +619,8 @@ impl> Processor { pub(super) fn mouse_report(&mut self, button: u8, state: ElementState) { let display_offset = self.ctx.terminal().grid().display_offset(); let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + let (point, _) = + self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side); // Assure the mouse point is not in the scrollback. if point.line < 0 { @@ -718,7 +724,10 @@ impl> Processor { let hint_point = { let view = self.ctx.display().pane_view(); let display_offset = self.ctx.terminal().grid().display_offset(); - self.ctx.mouse().point(&view, display_offset) + let point = self.ctx.mouse().point(&view, display_offset); + self.ctx + .terminal_math_source_point(point, self.ctx.mouse().cell_side) + .0 }; if let Some(hint) = crate::display::hint::highlighted_at( self.ctx.terminal(), @@ -1322,6 +1331,10 @@ impl> Processor { self.update_message_close_hover(); let display_offset = self.ctx.terminal().grid().display_offset(); let mut point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + point = self + .ctx + .terminal_math_source_point(point, self.ctx.mouse().cell_side) + .0; // `point` is clamped to `size_info`, but we're about to index the grid, // whose column/line count can trail `size_info` by one during a resize // or sidebar toggle (asymmetric-padding reflow lands a frame later). diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 036a72d..1d0f185 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2883,10 +2883,26 @@ impl WindowContext { } if self.dirty || self.mouse.hint_highlight_dirty { + let visual_point = self + .mouse + .point(&self.display.pane_view(), terminal.grid().display_offset()); + let pane = match focused { + Some(index) => &self.panes[index], + None => &self.doc_pane, + }; + let hint_point = pane + .nebula_state + .terminal_math_source_point( + visual_point, + self.mouse.cell_side, + terminal.grid().display_offset(), + ) + .0; self.dirty |= self.display.update_highlighted_hints( &terminal, &self.config, &self.mouse, + hint_point, self.modifiers.state(), ); self.mouse.hint_highlight_dirty = false;