Skip to main content

sim_lib_view_doc/
lens.rs

1//! The article lenses: formatted view, source view, edit decoding, and export.
2//!
3//! Both lenses render one shared [`sim_codec_doc::MarkupDoc`] value. The
4//! formatted lens projects semantic blocks to Scene nodes, the source lens shows
5//! the deterministic source text for the same markup value, and intent decoding
6//! turns validated edit-field gestures into reversible markup edits.
7
8use sim_codec_doc::{Inline, MarkupBlock, MarkupDoc, MarkupEdit};
9use sim_kernel::{Error, Expr, NumberLiteral, Result, Symbol};
10use sim_lib_scene::{node, sym};
11use sim_lib_view::{Mode, universal_scene};
12
13use crate::doc::{article_from_markup, markup_from_article, title};
14
15/// The formatted article lens id.
16pub const ARTICLE_FORMATTED_LENS: &str = "view:doc-article";
17
18/// The source article lens id.
19pub const ARTICLE_SOURCE_LENS: &str = "view:doc-source";
20
21/// Attach a cached rendered output (a Scene) to an embed block.
22pub fn with_cache(block: Expr, scene: Expr) -> Expr {
23    let Expr::Map(mut entries) = block else {
24        return block;
25    };
26    entries.push((Expr::Symbol(Symbol::new("cache")), scene));
27    Expr::Map(entries)
28}
29
30/// Render the formatted article: outline plus a block canvas.
31pub fn article_formatted(doc: &Expr) -> Expr {
32    let markup = markup_or_empty(doc);
33    let canvas = markup.blocks.iter().map(block_formatted).collect();
34    node(
35        "stack",
36        vec![
37            ("role", sym("article")),
38            ("dir", sym("row")),
39            (
40                "children",
41                Expr::List(vec![
42                    article_outline(doc),
43                    node(
44                        "stack",
45                        vec![
46                            ("role", sym("canvas")),
47                            ("dir", sym("column")),
48                            ("children", Expr::List(canvas)),
49                        ],
50                    ),
51                ]),
52            ),
53        ],
54    )
55}
56
57/// Render the source article as deterministic source text.
58pub fn article_source(doc: &Expr) -> Expr {
59    let markup = markup_or_empty(doc);
60    node(
61        "stack",
62        vec![
63            ("role", sym("source")),
64            ("dir", sym("column")),
65            ("children", Expr::List(vec![text(source_text(&markup))])),
66        ],
67    )
68}
69
70/// The outline: a tree of the article's section headings.
71pub fn article_outline(doc: &Expr) -> Expr {
72    let markup = markup_or_empty(doc);
73    let sections = markup
74        .blocks
75        .iter()
76        .filter_map(|block| match block {
77            MarkupBlock::Heading { text, .. } => Some(text_node(inline_text(text))),
78            _ => None,
79        })
80        .collect();
81    node(
82        "tree",
83        vec![
84            ("label", Expr::String("outline".to_owned())),
85            ("nodes", Expr::List(sections)),
86        ],
87    )
88}
89
90/// Decode a document edit Intent into a reversible markup edit.
91///
92/// # Errors
93///
94/// Returns an error when the Intent is not `intent/edit-field`, its path does
95/// not address a block, or its replacement value is not a markup block.
96pub fn markup_edit_from_intent(doc: &Expr, intent: &Expr) -> Result<MarkupEdit> {
97    let kind = sim_lib_intent::intent_kind_of(intent)
98        .ok_or_else(|| Error::Eval("intent is missing kind".to_owned()))?;
99    if kind != sim_lib_intent::intent_kind("edit-field") {
100        return Err(Error::Eval("expected intent/edit-field".to_owned()));
101    }
102    let markup = markup_from_article(doc)?;
103    let path = sim_lib_intent::field(intent, "path")
104        .ok_or_else(|| Error::Eval("edit intent is missing path".to_owned()))?;
105    let index = block_index_from_path(path)?;
106    let old = markup
107        .blocks
108        .get(index)
109        .cloned()
110        .ok_or_else(|| Error::Eval("edit intent block index is out of range".to_owned()))?;
111    let value = sim_lib_intent::field(intent, "value")
112        .ok_or_else(|| Error::Eval("edit intent is missing value".to_owned()))?;
113    let new = MarkupBlock::from_expr(value)?;
114    Ok(MarkupEdit::ReplaceBlock { index, old, new })
115}
116
117/// The stable intermediate document value used for export.
118pub fn export_intermediate(doc: &Expr) -> Expr {
119    article_from_markup(&markup_or_empty(doc))
120}
121
122/// Export the document through the shared markup source writer.
123pub fn export_markdown(doc: &Expr) -> String {
124    source_text(&markup_or_empty(doc))
125}
126
127fn markup_or_empty(doc: &Expr) -> MarkupDoc {
128    markup_from_article(doc).unwrap_or_else(|_| MarkupDoc {
129        title: title(doc).or_else(|| Some("untitled".to_owned())),
130        blocks: Vec::new(),
131        attrs: Default::default(),
132        source: None,
133    })
134}
135
136fn source_text(markup: &MarkupDoc) -> String {
137    let body = markup.to_source_text();
138    let Some(title) = &markup.title else {
139        return body;
140    };
141    if title_is_first_heading(markup, title) {
142        return body;
143    }
144    if body.is_empty() {
145        format!("# {title}\n")
146    } else {
147        format!("# {title}\n\n{body}")
148    }
149}
150
151fn title_is_first_heading(markup: &MarkupDoc, title: &str) -> bool {
152    matches!(
153        markup.blocks.first(),
154        Some(MarkupBlock::Heading { level: 1, text, .. }) if inline_text(text) == title
155    )
156}
157
158fn block_formatted(block: &MarkupBlock) -> Expr {
159    match block {
160        MarkupBlock::Heading { text, .. } => node(
161            "text",
162            vec![
163                ("role", sym("heading")),
164                ("text", Expr::String(inline_text(text))),
165            ],
166        ),
167        MarkupBlock::Paragraph { content, .. } => {
168            node("text", vec![("text", Expr::String(inline_text(content)))])
169        }
170        MarkupBlock::CodeBlock { code, .. } => boxed_text("code", code),
171        MarkupBlock::MathBlock { source, .. } => boxed_text("equation", &source.text),
172        MarkupBlock::Quote { blocks, .. } => node(
173            "box",
174            vec![
175                ("role", sym("quote")),
176                (
177                    "children",
178                    Expr::List(blocks.iter().map(block_formatted).collect()),
179                ),
180            ],
181        ),
182        MarkupBlock::List { items, .. } => node(
183            "stack",
184            vec![
185                ("role", sym("list")),
186                ("dir", sym("column")),
187                (
188                    "children",
189                    Expr::List(
190                        items
191                            .iter()
192                            .map(|item| {
193                                node(
194                                    "stack",
195                                    vec![
196                                        ("role", sym("list-item")),
197                                        ("dir", sym("column")),
198                                        (
199                                            "children",
200                                            Expr::List(item.iter().map(block_formatted).collect()),
201                                        ),
202                                    ],
203                                )
204                            })
205                            .collect(),
206                    ),
207                ),
208            ],
209        ),
210        MarkupBlock::Table { header, rows, .. } => table_scene(header, rows),
211        MarkupBlock::Figure { src, caption, .. } => node(
212            "box",
213            vec![
214                ("role", sym("figure")),
215                ("src", Expr::String(src.clone())),
216                (
217                    "children",
218                    Expr::List(vec![text_node(inline_text(caption))]),
219                ),
220            ],
221        ),
222        MarkupBlock::Raw { backend, text, .. } if backend.as_str() == "view-doc/embed" => {
223            embed_scene(text)
224        }
225        MarkupBlock::Raw { backend, text, .. } if backend.as_str() == "view-doc/citation" => node(
226            "text",
227            vec![
228                ("role", sym("citation")),
229                ("text", Expr::String(text.clone())),
230            ],
231        ),
232        MarkupBlock::Raw { text, .. } => text_node(text.clone()),
233    }
234}
235
236fn boxed_text(role: &str, content: &str) -> Expr {
237    node(
238        "box",
239        vec![
240            ("role", sym(role)),
241            ("children", Expr::List(vec![text_node(content.to_owned())])),
242        ],
243    )
244}
245
246fn embed_scene(text: &str) -> Expr {
247    let inner = universal_scene(&Expr::String(text.to_owned()), Mode::Builder);
248    node(
249        "embed",
250        vec![
251            ("lens", Expr::Symbol(Symbol::new("view:default"))),
252            ("scene", inner),
253        ],
254    )
255}
256
257fn table_scene(header: &[Vec<Inline>], rows: &[Vec<Vec<Inline>>]) -> Expr {
258    let rows = std::iter::once(header)
259        .chain(rows.iter().map(Vec::as_slice))
260        .map(|row| {
261            Expr::List(
262                row.iter()
263                    .map(|cell| Expr::String(inline_text(cell)))
264                    .collect(),
265            )
266        })
267        .collect();
268    node("table", vec![("rows", Expr::List(rows))])
269}
270
271fn block_index_from_path(path: &Expr) -> Result<usize> {
272    let segments = path_segments(path)?;
273    if segments.len() != 2 || segment_name(segments[0]).as_deref() != Some("blocks") {
274        return Err(Error::Eval(
275            "edit intent path must address blocks/<index>".to_owned(),
276        ));
277    }
278    segment_index(segments[1])
279}
280
281fn path_segments(path: &Expr) -> Result<Vec<&Expr>> {
282    match path {
283        Expr::List(items) if items.len() == 1 => match &items[0] {
284            Expr::Vector(items) => Ok(items.iter().collect()),
285            _ => Ok(items.iter().collect()),
286        },
287        Expr::List(items) => Ok(items.iter().collect()),
288        _ => Err(Error::Eval("edit intent path must be a list".to_owned())),
289    }
290}
291
292fn segment_name(segment: &Expr) -> Option<String> {
293    match segment {
294        Expr::String(text) => Some(text.clone()),
295        Expr::Symbol(symbol) => Some(symbol.name.to_string()),
296        _ => None,
297    }
298}
299
300fn segment_index(segment: &Expr) -> Result<usize> {
301    match segment {
302        Expr::Number(NumberLiteral { canonical, .. }) => canonical
303            .parse()
304            .map_err(|_| Error::Eval("block index must be an integer".to_owned())),
305        Expr::String(text) => text
306            .parse()
307            .map_err(|_| Error::Eval("block index must be an integer".to_owned())),
308        _ => Err(Error::Eval("block index must be a number".to_owned())),
309    }
310}
311
312fn inline_text(items: &[Inline]) -> String {
313    let mut out = String::new();
314    for item in items {
315        match item {
316            Inline::Text(text) | Inline::Code(text) => out.push_str(text),
317            Inline::Emph(children) | Inline::Strong(children) => {
318                out.push_str(&inline_text(children))
319            }
320            Inline::Link { label, .. } => out.push_str(&inline_text(label)),
321            Inline::Math(source) => out.push_str(&source.text),
322            Inline::Raw { text, .. } => out.push_str(text),
323        }
324    }
325    out
326}
327
328fn text(content: String) -> Expr {
329    text_node(content)
330}
331
332fn text_node(content: String) -> Expr {
333    node("text", vec![("text", Expr::String(content))])
334}