Skip to main content

fresh/view/
soft_break.rs

1//! Soft break infrastructure
2//!
3//! Provides a marker-based system for injecting soft line breaks during rendering.
4//! Used for compose-mode word wrapping: plugins register break points at byte positions,
5//! and markers auto-adjust on buffer edits so breaks survive without async round-trips.
6//!
7//! ## Architecture
8//!
9//! Follows the same pattern as ConcealManager:
10//! 1. Plugins add soft breaks via `addSoftBreak(bufferId, namespace, position, indent)`
11//! 2. Break positions are stored with marker-based tracking (auto-adjust on edits)
12//! 3. During the token pipeline, breaks are injected into the token stream
13//!
14//! ## Integration Point
15//!
16//! Soft breaks are applied to the token stream in `split_rendering.rs` BEFORE
17//! conceal ranges and wrapping. This means:
18//! - Concealment operates on the already-broken lines
19//! - The wrapping transform sees pre-broken content
20
21use crate::model::marker::{MarkerId, MarkerList};
22use fresh_core::overlay::OverlayNamespace;
23use std::collections::HashMap;
24
25/// A soft break point that injects a line break during rendering
26#[derive(Debug, Clone)]
27pub struct SoftBreakPoint {
28    /// Namespace for bulk operations (shared with overlay namespace system)
29    pub namespace: OverlayNamespace,
30
31    /// Marker at the break position (right affinity — shifts with inserted text)
32    pub marker_id: MarkerId,
33
34    /// Number of hanging indent spaces to insert after the break
35    pub indent: u16,
36}
37
38impl SoftBreakPoint {
39    /// Get the current byte position by resolving the marker
40    pub fn position(&self, marker_list: &MarkerList) -> usize {
41        marker_list.get_position(self.marker_id).unwrap_or(0)
42    }
43
44    /// Check if this break point falls within a byte range
45    pub fn in_range(&self, start: usize, end: usize, marker_list: &MarkerList) -> bool {
46        let pos = self.position(marker_list);
47        pos >= start && pos < end
48    }
49}
50
51/// Manages soft break points for a buffer
52#[derive(Debug, Clone)]
53pub struct SoftBreakManager {
54    breaks: Vec<SoftBreakPoint>,
55    /// `MarkerId -> index into breaks` for O(log N + k) `remove_in_range`.
56    /// Each break has exactly one marker. Kept in sync with every push /
57    /// swap_remove on `breaks`.
58    marker_to_idx: HashMap<MarkerId, usize>,
59    /// Monotonic counter bumped on every mutation. Consumers that cache derived
60    /// data (e.g. `LineWrapCache`) fold this into their key so any mutation
61    /// invalidates stale entries automatically.
62    version: u32,
63}
64
65impl SoftBreakManager {
66    /// Create a new empty soft break manager
67    pub fn new() -> Self {
68        Self {
69            breaks: Vec::new(),
70            marker_to_idx: HashMap::new(),
71            version: 0,
72        }
73    }
74
75    /// Monotonic version, bumped on every mutation.
76    pub fn version(&self) -> u32 {
77        self.version
78    }
79
80    /// Add a soft break point
81    pub fn add(
82        &mut self,
83        marker_list: &mut MarkerList,
84        namespace: OverlayNamespace,
85        position: usize,
86        indent: u16,
87    ) {
88        let marker_id = marker_list.create(position, false); // right affinity
89
90        let idx = self.breaks.len();
91        self.marker_to_idx.insert(marker_id, idx);
92        self.breaks.push(SoftBreakPoint {
93            namespace,
94            marker_id,
95            indent,
96        });
97        self.version = self.version.wrapping_add(1);
98    }
99
100    /// Remove all soft breaks in a namespace
101    pub fn clear_namespace(&mut self, namespace: &OverlayNamespace, marker_list: &mut MarkerList) {
102        let mut indices: Vec<usize> = self
103            .breaks
104            .iter()
105            .enumerate()
106            .filter_map(|(i, b)| (&b.namespace == namespace).then_some(i))
107            .collect();
108        if indices.is_empty() {
109            return;
110        }
111        indices.sort_unstable_by(|a, b| b.cmp(a));
112        for idx in indices {
113            self.swap_remove_at(idx, marker_list);
114        }
115        self.version = self.version.wrapping_add(1);
116    }
117
118    /// Remove all soft breaks that fall within a byte range and clean up their markers
119    pub fn remove_in_range(&mut self, start: usize, end: usize, marker_list: &mut MarkerList) {
120        // O(log N + k): query the marker tree for points in `[start, end]`,
121        // map back to entries, verify position is in `[start, end)` (the
122        // marker tree query is closed; soft-break membership is half-open).
123        if start >= end {
124            return;
125        }
126        let hits = marker_list.query_range(start, end);
127        if hits.is_empty() {
128            return;
129        }
130        let mut to_remove: Vec<usize> = hits
131            .iter()
132            .filter_map(|(mid, pos, _)| {
133                if *pos < end {
134                    self.marker_to_idx.get(mid).copied()
135                } else {
136                    None
137                }
138            })
139            .collect();
140        to_remove.sort_unstable();
141        to_remove.dedup();
142        if to_remove.is_empty() {
143            return;
144        }
145        // Descending so swap_remove doesn't shift earlier indices.
146        to_remove.sort_unstable_by(|a, b| b.cmp(a));
147        for idx in to_remove {
148            self.swap_remove_at(idx, marker_list);
149        }
150        self.version = self.version.wrapping_add(1);
151    }
152
153    /// Clear all soft breaks and their markers
154    pub fn clear(&mut self, marker_list: &mut MarkerList) {
155        let had_any = !self.breaks.is_empty();
156        for bp in &self.breaks {
157            marker_list.delete(bp.marker_id);
158        }
159        self.breaks.clear();
160        self.marker_to_idx.clear();
161        if had_any {
162            self.version = self.version.wrapping_add(1);
163        }
164    }
165
166    /// Swap-remove the entry at `idx`, deleting its marker and patching
167    /// `marker_to_idx` for whatever entry got swapped in.
168    fn swap_remove_at(&mut self, idx: usize, marker_list: &mut MarkerList) {
169        let removed = self.breaks.swap_remove(idx);
170        self.marker_to_idx.remove(&removed.marker_id);
171        marker_list.delete(removed.marker_id);
172        if let Some(moved) = self.breaks.get(idx) {
173            self.marker_to_idx.insert(moved.marker_id, idx);
174        }
175    }
176
177    /// Query soft breaks that fall within a viewport range.
178    /// Returns sorted `(position, indent)` pairs for efficient token processing.
179    pub fn query_viewport(
180        &self,
181        start: usize,
182        end: usize,
183        marker_list: &MarkerList,
184    ) -> Vec<(usize, u16)> {
185        let mut results: Vec<(usize, u16)> = self
186            .breaks
187            .iter()
188            .filter_map(|b| {
189                let pos = b.position(marker_list);
190                if pos >= start && pos < end {
191                    Some((pos, b.indent))
192                } else {
193                    None
194                }
195            })
196            .collect();
197
198        // Sort by position for sequential processing
199        results.sort_by_key(|(pos, _)| *pos);
200
201        results
202    }
203
204    /// Returns true if there are no soft breaks
205    pub fn is_empty(&self) -> bool {
206        self.breaks.is_empty()
207    }
208
209    /// Test-only: assert `marker_to_idx` is consistent with `breaks`.
210    /// Panics on any divergence. Used by property tests.
211    #[cfg(test)]
212    fn check_invariants(&self) {
213        assert_eq!(
214            self.marker_to_idx.len(),
215            self.breaks.len(),
216            "marker_to_idx size != breaks size"
217        );
218        for (i, b) in self.breaks.iter().enumerate() {
219            let mapped = self.marker_to_idx.get(&b.marker_id).copied();
220            assert_eq!(
221                mapped,
222                Some(i),
223                "marker {:?} should map to idx {} but maps to {:?}",
224                b.marker_id,
225                i,
226                mapped
227            );
228        }
229    }
230}
231
232impl Default for SoftBreakManager {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn ns() -> OverlayNamespace {
243        OverlayNamespace::from_string("test".to_string())
244    }
245
246    #[test]
247    fn test_soft_break_remove_in_range_keeps_only_outside() {
248        let mut marker_list = MarkerList::new();
249        marker_list.set_buffer_size(200);
250        let mut manager = SoftBreakManager::new();
251
252        manager.add(&mut marker_list, ns(), 5, 0);
253        manager.add(&mut marker_list, ns(), 25, 0);
254        manager.add(&mut marker_list, ns(), 45, 0);
255        manager.add(&mut marker_list, ns(), 65, 0);
256
257        // Remove [20..50): 25 and 45 are inside, 5 and 65 stay.
258        manager.remove_in_range(20, 50, &mut marker_list);
259
260        let kept: Vec<_> = manager
261            .query_viewport(0, 1000, &marker_list)
262            .into_iter()
263            .map(|(p, _)| p)
264            .collect();
265        assert_eq!(kept, vec![5, 65]);
266    }
267
268    #[test]
269    fn test_soft_break_remove_in_range_endpoint_semantics() {
270        // Half-open: pos == start removed, pos == end kept.
271        let mut marker_list = MarkerList::new();
272        marker_list.set_buffer_size(100);
273        let mut manager = SoftBreakManager::new();
274
275        manager.add(&mut marker_list, ns(), 10, 0);
276        manager.add(&mut marker_list, ns(), 20, 0);
277
278        manager.remove_in_range(10, 20, &mut marker_list);
279        let kept: Vec<_> = manager
280            .query_viewport(0, 1000, &marker_list)
281            .into_iter()
282            .map(|(p, _)| p)
283            .collect();
284        assert_eq!(kept, vec![20]);
285    }
286
287    #[test]
288    fn test_soft_break_remove_in_range_bumps_version_only_on_change() {
289        let mut marker_list = MarkerList::new();
290        marker_list.set_buffer_size(100);
291        let mut manager = SoftBreakManager::new();
292
293        manager.add(&mut marker_list, ns(), 10, 0);
294        let v0 = manager.version();
295
296        manager.remove_in_range(50, 60, &mut marker_list);
297        assert_eq!(manager.version(), v0);
298
299        manager.remove_in_range(0, 50, &mut marker_list);
300        assert!(manager.is_empty());
301        assert_ne!(manager.version(), v0);
302    }
303
304    /// Mirrors the production cycle: per line in `lines_changed`, clear
305    /// soft breaks in the line's byte range, then re-add the line's
306    /// soft breaks. Same shape as the matching conceal/overlay perf
307    /// tests for direct comparison.
308    ///
309    /// Run with:
310    ///   cargo nextest run -p fresh-editor --no-capture \
311    ///     view::soft_break::tests::perf_full_buffer_rebuild_pass
312    #[test]
313    fn perf_full_buffer_rebuild_pass() {
314        const LINES: usize = 500;
315        const LINE_BYTES: usize = 50;
316        const BREAKS_PER_LINE: usize = 5;
317
318        let mut marker_list = MarkerList::new();
319        marker_list.set_buffer_size(LINES * LINE_BYTES);
320        let mut manager = SoftBreakManager::new();
321
322        let break_byte = |line: usize, k: usize| -> usize {
323            line * LINE_BYTES + k * (LINE_BYTES / BREAKS_PER_LINE)
324        };
325
326        // Populate steady state.
327        for line in 0..LINES {
328            for k in 0..BREAKS_PER_LINE {
329                manager.add(&mut marker_list, ns(), break_byte(line, k), 0);
330            }
331        }
332        let initial = LINES * BREAKS_PER_LINE;
333
334        // One full-buffer `lines_changed` pass: per line, clear + re-add.
335        let start = std::time::Instant::now();
336        for line in 0..LINES {
337            let line_start = line * LINE_BYTES;
338            let line_end = line_start + LINE_BYTES;
339            manager.remove_in_range(line_start, line_end, &mut marker_list);
340            for k in 0..BREAKS_PER_LINE {
341                manager.add(&mut marker_list, ns(), break_byte(line, k), 0);
342            }
343        }
344        let elapsed = start.elapsed();
345
346        eprintln!(
347            "[perf] soft_break full-buffer rebuild ({LINES} lines, {} entries steady): \
348             {:?} total, {:?}/line",
349            initial,
350            elapsed,
351            elapsed / LINES as u32,
352        );
353        let still_present = manager
354            .query_viewport(0, LINES * LINE_BYTES, &marker_list)
355            .len();
356        assert_eq!(still_present, initial);
357    }
358
359    mod proptests {
360        use super::*;
361        use proptest::prelude::*;
362
363        #[derive(Debug, Clone)]
364        enum Op {
365            Add { pos: usize, indent: u16, ns_idx: u8 },
366            RemoveInRange { start: usize, end: usize },
367            ClearNamespace { ns_idx: u8 },
368        }
369
370        const BUFFER_SIZE: usize = 200;
371
372        fn arb_op() -> impl Strategy<Value = Op> {
373            prop_oneof![
374                3 => (0..BUFFER_SIZE, 0u16..8u16, 0u8..3u8)
375                    .prop_map(|(pos, indent, ns_idx)| Op::Add { pos, indent, ns_idx }),
376                2 => (0..BUFFER_SIZE, 0..BUFFER_SIZE)
377                    .prop_map(|(a, b)| {
378                        let (s, e) = if a <= b { (a, b) } else { (b, a) };
379                        Op::RemoveInRange { start: s, end: e }
380                    }),
381                1 => (0u8..3u8).prop_map(|ns_idx| Op::ClearNamespace { ns_idx }),
382            ]
383        }
384
385        fn nsf(idx: u8) -> OverlayNamespace {
386            OverlayNamespace::from_string(format!("ns{idx}"))
387        }
388
389        proptest! {
390            /// Invariants must hold after every sequence of operations.
391            /// Plus: after `remove_in_range(r)`, no surviving entry's
392            /// position lies in `[r.start, r.end)`.
393            #[test]
394            fn prop_marker_index_consistent(ops in prop::collection::vec(arb_op(), 0..40)) {
395                let mut marker_list = MarkerList::new();
396                marker_list.set_buffer_size(BUFFER_SIZE);
397                let mut manager = SoftBreakManager::new();
398
399                for op in ops {
400                    match op {
401                        Op::Add { pos, indent, ns_idx } => {
402                            manager.add(&mut marker_list, nsf(ns_idx), pos, indent);
403                        }
404                        Op::RemoveInRange { start, end } => {
405                            manager.remove_in_range(start, end, &mut marker_list);
406                            // No surviving entry inside the removed range.
407                            for (p, _) in manager.query_viewport(0, BUFFER_SIZE, &marker_list) {
408                                prop_assert!(
409                                    !(p >= start && p < end),
410                                    "entry at {p} survived remove_in_range({start}..{end})",
411                                );
412                            }
413                        }
414                        Op::ClearNamespace { ns_idx } => {
415                            manager.clear_namespace(&nsf(ns_idx), &mut marker_list);
416                        }
417                    }
418                    manager.check_invariants();
419                }
420            }
421        }
422    }
423}