Skip to main content

strop_engine/editor/collections/
projection.rs

1//! Source-to-view rendering and incremental projection updates.
2use super::{Collection, CollectionRow, CollectionRowInfo, Document, Editor};
3use strop_core::id::{Arena, DocumentId, DocumentKind};
4use strop_core::Range;
5
6/// The canonical rendering (0049 §6): a title row with counts, then ONE
7/// CARD PER FILE — top border with path and badges, excerpt bodies with
8/// a "⋮ N source lines omitted" gap between disjoint spans, and a bottom
9/// border. Row roles are recorded in `collection.rows` for the renderer;
10/// chrome rows are protected (write-back refuses them) and keep the
11/// excerpt's `view_line` invariant: the row before the body.
12pub(super) fn render(
13    docs: &Arena<DocumentKind, Document>,
14    cwd: &std::path::Path,
15    collection: &mut Collection,
16) -> String {
17    let files = collection
18        .excerpts
19        .iter()
20        .enumerate()
21        .filter(|(index, excerpt)| {
22            *index == 0 || collection.excerpts[index - 1].source != excerpt.source
23        })
24        .count();
25    let unavailable = if collection.skipped == 0 {
26        String::new()
27    } else {
28        format!(" · {} unavailable hit(s)", collection.skipped)
29    };
30    let mut text = format!(
31        "collection: {} — {} match(es) · {} file(s){unavailable}\n",
32        collection.title, collection.match_count, files
33    );
34    let mut rows = vec![CollectionRow::Title];
35    let mut line = 1;
36    let mut at = 0;
37    while at < collection.excerpts.len() {
38        let source = collection.excerpts[at].source;
39        let mut card_end = at;
40        while card_end + 1 < collection.excerpts.len()
41            && collection.excerpts[card_end + 1].source == source
42        {
43            card_end += 1;
44        }
45        let doc = docs.get(source).unwrap();
46        let path = doc.label(cwd);
47        let lang = doc
48            .syntax_path()
49            .and_then(|path| path.extension())
50            .and_then(|extension| extension.to_str())
51            .and_then(strop_lsp::registry::language_for_extension_name);
52        let count = card_end - at + 1;
53        let lang_badge = lang.map(|l| format!("{l} · ")).unwrap_or_default();
54        let context = collection.excerpts[at..=card_end]
55            .iter()
56            .map(|excerpt| excerpt.context)
57            .max()
58            .unwrap_or(0);
59        text.push_str(&format!(
60            "╭─ {path} ── {lang_badge}{count} excerpt(s) · context {context} (+/-)\n"
61        ));
62        rows.push(CollectionRow::CardTop(at));
63        line += 1;
64        for i in at..=card_end {
65            if i > at {
66                let prev = &collection.excerpts[i - 1];
67                let here = &collection.excerpts[i];
68                let gap_lines = doc
69                    .buf
70                    .line_of(here.start)
71                    .saturating_sub(doc.buf.line_of(prev.end));
72                text.push_str(&format!("⋮ {gap_lines} source lines omitted\n"));
73                rows.push(CollectionRow::Gap);
74                line += 1;
75            }
76            let start = collection.excerpts[i].start;
77            let end = collection.excerpts[i].end;
78            let body = doc.buf.text().byte_slice(start..end).to_string();
79            let excerpt = &mut collection.excerpts[i];
80            excerpt.view_line = line - 1; // the chrome row before the body
81            excerpt.view_start = text.len();
82            excerpt.view_lines = body.lines().count().max(1);
83            text.push_str(&body);
84            if !body.ends_with('\n') {
85                text.push('\n');
86            }
87            excerpt.view_end = text.len();
88            for _ in 0..excerpt.view_lines {
89                rows.push(CollectionRow::Body);
90            }
91            line += excerpt.view_lines;
92        }
93        text.push_str("╰\n");
94        rows.push(CollectionRow::CardBottom);
95        line += 1;
96        at = card_end + 1;
97    }
98    collection.rows = rows;
99    text
100}
101
102impl Editor {
103    /// Re-render the collection view from its sources and reset shadow +
104    /// revision together (0049 §5: no stale text may be presented).
105    pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
106        // Preserve the logical caret across the regeneration (0049 §5):
107        // same row/column clamped into the new text.
108        let caret = if self.current() == id {
109            Some((
110                self.buf().line_of(self.head()),
111                self.buf().col_of(self.head()),
112            ))
113        } else {
114            None
115        };
116        let text = {
117            let entry = self.collections.get_mut(&id).unwrap();
118            render(&self.docs, &self.cwd, entry)
119        };
120        let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
121        if let Some((line, col)) = caret {
122            let line = line.min(self.docs.get(id).unwrap().buf.len_lines().saturating_sub(1));
123            let head = self.docs.get(id).unwrap().buf.clamp_boundary(
124                self.docs
125                    .get(id)
126                    .unwrap()
127                    .buf
128                    .line_start(line)
129                    .saturating_add(col),
130            );
131            if self.current() == id {
132                self.set_head(head);
133                self.clamp_cursor();
134            }
135        }
136        let revision = self.docs.get(id).unwrap().buf.revision();
137        self.collections.get_mut(&id).unwrap().revision = revision;
138        // The view is a presentation: its dirty bit is never the story
139        // (0049 §5 — the sources own unsaved state).
140        self.docs.get_mut(id).unwrap().buf.dirty = false;
141    }
142
143    /// A source change strictly inside one excerpt (0049 §5): splice the
144    /// excerpt's view span with the new source body instead of
145    /// re-rendering the whole view. The caller gates on a clean view
146    /// (no unsynced user edit) — the spans assume shadow == view.
147    pub(crate) fn collection_splice_excerpt(&mut self, id: DocumentId, index: usize) {
148        let (source, view_start, view_end, view_lines) = {
149            let entry = &self.collections[&id];
150            let excerpt = &entry.excerpts[index];
151            (
152                excerpt.source,
153                excerpt.view_start,
154                excerpt.view_end,
155                excerpt.view_lines,
156            )
157        };
158        let Some(source_doc) = self.docs.get(source) else {
159            return;
160        };
161        let excerpt_span = {
162            let entry = &self.collections[&id];
163            let excerpt = &entry.excerpts[index];
164            (excerpt.start, excerpt.end)
165        };
166        let mut body = source_doc
167            .buf
168            .text()
169            .byte_slice(excerpt_span.0..excerpt_span.1)
170            .to_string();
171        if !body.ends_with('\n') {
172            body.push('\n');
173        }
174        let new_lines = body.lines().count().max(1);
175        // Splice the collection buffer at the excerpt's view span; the
176        // view is clean, so the spans index it directly.
177        {
178            let doc = self.docs.get_mut(id).unwrap();
179            let _ = doc
180                .buf
181                .system_edit()
182                .replace(Range::charwise(view_start, view_end), &body);
183        }
184        // The splice's own journal entry feeds the view analysis and is
185        // then consumed: the write-back diff must never see it.
186        {
187            let doc = self.docs.get_mut(id).unwrap();
188            let changes: Vec<_> = doc.buf.changes().to_vec();
189            self.analysis.edits(id, &changes);
190            doc.buf.clear_changes();
191        }
192        let byte_delta = body.len() as isize - (view_end - view_start) as isize;
193        let line_delta = new_lines as isize - view_lines as isize;
194        let entry = self.collections.get_mut(&id).unwrap();
195        let first_row = entry.excerpts[index].view_line + 1;
196        entry.rows.splice(
197            first_row..first_row + view_lines,
198            std::iter::repeat_n(CollectionRow::Body, new_lines),
199        );
200        let mut seen = false;
201        for excerpt in &mut entry.excerpts {
202            if seen {
203                excerpt.view_start = (excerpt.view_start as isize + byte_delta) as usize;
204                excerpt.view_end = (excerpt.view_end as isize + byte_delta) as usize;
205                excerpt.view_line = (excerpt.view_line as isize + line_delta) as usize;
206            } else if excerpt.view_start == view_start {
207                seen = true;
208                excerpt.view_lines = new_lines;
209                excerpt.view_end = view_start + body.len();
210            }
211        }
212        let revision = self.docs.get(id).unwrap().buf.revision();
213        self.collections.get_mut(&id).unwrap().revision = revision;
214    }
215}
216
217impl Editor {
218    /// Per-row source facts for the renderer (0049 §6): the row's role,
219    /// and for body rows the source document + this row's source byte
220    /// span + the query hits inside it (source bytes) + whether the
221    /// caret sits in this card.
222    pub fn collection_row_info(
223        &self,
224        doc: strop_core::id::DocumentId,
225        line: usize,
226    ) -> Option<CollectionRowInfo> {
227        let collection = self.collections.get(&doc)?;
228        let kind = *collection.rows.get(line)?;
229        let caret_line = if doc == self.current() {
230            self.buf().line_of(self.head())
231        } else {
232            usize::MAX
233        };
234        let mut info = CollectionRowInfo {
235            kind,
236            source: None,
237            source_matches: Vec::new(),
238            card_active: false,
239            source_dirty: false,
240            source_readonly: false,
241        };
242        let index = collection
243            .excerpts
244            .partition_point(|excerpt| excerpt.view_line <= line);
245        if let Some(excerpt) = index
246            .checked_sub(1)
247            .and_then(|index| collection.excerpts.get(index))
248        {
249            if let Some(source) = self.docs.get(excerpt.source) {
250                info.source_dirty = source.buf.dirty;
251                info.source_readonly = source.buf.readonly;
252            }
253            let active_index = collection
254                .excerpts
255                .partition_point(|excerpt| excerpt.view_line <= caret_line);
256            info.card_active = caret_line != usize::MAX
257                && active_index
258                    .checked_sub(1)
259                    .and_then(|index| collection.excerpts.get(index))
260                    .is_some_and(|active| active.source == excerpt.source);
261            if kind == CollectionRow::Body
262                && line > excerpt.view_line
263                && line <= excerpt.view_line + excerpt.view_lines
264            {
265                let source = self.docs.get(excerpt.source)?;
266                let source_line = source.buf.line_of(excerpt.start) + line - excerpt.view_line - 1;
267                let start = source.buf.line_start(source_line);
268                let end = source.buf.line_end(source_line);
269                info.source = Some((excerpt.source, start, end));
270                let first = excerpt
271                    .matches
272                    .partition_point(|(offset, length)| offset.saturating_add(*length) <= start);
273                info.source_matches = excerpt.matches[first..]
274                    .iter()
275                    .copied()
276                    .take_while(|(offset, _)| *offset < end)
277                    .map(|(offset, length)| {
278                        (
279                            offset.max(start),
280                            offset.saturating_add(length).min(end) - offset.max(start),
281                        )
282                    })
283                    .collect();
284            }
285        }
286        Some(info)
287    }
288
289    /// A collection view row's role (0049 §6) for the renderer —
290    /// structure as data, never text parsing.
291    pub fn collection_row_kind(
292        &self,
293        doc: strop_core::id::DocumentId,
294        line: usize,
295    ) -> Option<CollectionRow> {
296        self.collections.get(&doc)?.rows.get(line).copied()
297    }
298
299    /// The SOURCE line number for a collection view row (0049 §6):
300    /// Some(Some(n)) for body rows, Some(None) for chrome (title,
301    /// headers — the gutter stays blank there), None for ordinary
302    /// buffers.
303    pub fn collection_source_lineno(
304        &self,
305        doc: strop_core::id::DocumentId,
306        line: usize,
307    ) -> Option<Option<usize>> {
308        let collection = self.collections.get(&doc)?;
309        let index = collection
310            .excerpts
311            .partition_point(|excerpt| excerpt.view_line < line);
312        if let Some(excerpt) = index
313            .checked_sub(1)
314            .and_then(|index| collection.excerpts.get(index))
315        {
316            if line <= excerpt.view_line + excerpt.view_lines {
317                let source = self.docs.get(excerpt.source)?;
318                return Some(Some(
319                    source.buf.line_of(excerpt.start) + line - excerpt.view_line,
320                ));
321            }
322        }
323        Some(None) // title row
324    }
325}
326
327impl Editor {
328    pub fn collection_unsaved(&self, document: DocumentId) -> bool {
329        self.collections.get(&document).is_some_and(|collection| {
330            collection.excerpts.iter().any(|excerpt| {
331                self.docs
332                    .get(excerpt.source)
333                    .is_some_and(|source| source.buf.dirty)
334            })
335        })
336    }
337}
338
339impl Editor {
340    /// Map a real buffer byte to its authoritative source; chrome has no source byte.
341    pub fn source_position(
342        &self,
343        document: DocumentId,
344        byte: usize,
345    ) -> Option<(DocumentId, usize)> {
346        let view = self.docs.get(document)?;
347        let Some(collection) = self.collections.get(&document) else {
348            return Some((
349                document,
350                view.buf.clamp_boundary(byte.min(view.buf.len_bytes())),
351            ));
352        };
353        let after = collection
354            .excerpts
355            .partition_point(|excerpt| excerpt.view_start <= byte);
356        let excerpt = collection.excerpts.get(after.checked_sub(1)?)?;
357        if byte >= excerpt.view_end {
358            return None;
359        }
360        let source = self.docs.get(excerpt.source)?;
361        let offset = (excerpt.start + byte - excerpt.view_start).min(excerpt.end);
362        Some((
363            excerpt.source,
364            source
365                .buf
366                .clamp_boundary(offset.min(source.buf.len_bytes())),
367        ))
368    }
369}