Skip to main content

nightshade_api/web/
code_surface.rs

1//! Read-only, virtualized code viewer with brace-based code folding.
2
3use std::collections::{HashMap, HashSet};
4
5use leptos::html;
6use leptos::prelude::*;
7use wasm_bindgen::JsCast;
8
9use crate::web::visible_range;
10
11use crate::web::code_editor::Highlighter;
12
13fn fold_regions(lines: &[&str]) -> Vec<(usize, usize)> {
14    let mut stack: Vec<usize> = Vec::new();
15    let mut regions = Vec::new();
16    for (index, line) in lines.iter().enumerate() {
17        for character in line.chars() {
18            if character == '{' {
19                stack.push(index);
20            } else if character == '}'
21                && let Some(start) = stack.pop()
22                && index > start
23            {
24                regions.push((start, index));
25            }
26        }
27    }
28    regions
29}
30
31/// A read-only, virtualized code view of `value`: renders only the rows in the
32/// visible window (plus `overscan`) at a fixed `line_height` within a scrollable
33/// area of the given `height`, applies the optional `highlighter`, and lets
34/// brace-delimited regions be folded from the gutter.
35#[component]
36pub fn CodeSurface(
37    #[prop(into)] value: Signal<String>,
38    #[prop(optional)] highlighter: Option<Highlighter>,
39    #[prop(default = 20.0)] line_height: f64,
40    #[prop(default = 420.0)] height: f64,
41    #[prop(default = 20)] overscan: usize,
42) -> impl IntoView {
43    let folds = RwSignal::new(HashSet::<usize>::new());
44    let scroll_top = RwSignal::new(0.0);
45    let viewport_height = RwSignal::new(height);
46    let wrap_ref = NodeRef::<html::Div>::new();
47
48    let on_scroll = move |event: web_sys::Event| {
49        if let Some(element) = event
50            .target()
51            .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
52        {
53            scroll_top.set(element.scroll_top() as f64);
54            viewport_height.set(element.client_height() as f64);
55        }
56    };
57
58    Effect::new(move |_| {
59        if let Some(element) = wrap_ref.get() {
60            viewport_height.set(element.client_height() as f64);
61        }
62    });
63
64    let model = Memo::new(move |_| {
65        let source = value.get();
66        let lines: Vec<String> = source.split('\n').map(str::to_string).collect();
67        let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
68        let regions = fold_regions(&refs);
69        (lines, regions)
70    });
71
72    let body = move || {
73        let folded = folds.get();
74        let view_height = viewport_height.get().max(line_height);
75        let scroll = scroll_top.get();
76        model.with(|(lines, regions)| {
77        let mut hidden = HashSet::new();
78        for (start, end) in regions {
79            if folded.contains(start) {
80                for line in (start + 1)..=*end {
81                    hidden.insert(line);
82                }
83            }
84        }
85        let headers: HashMap<usize, usize> = regions.iter().copied().collect();
86        let visible: Vec<usize> = (0..lines.len())
87            .filter(|line| !hidden.contains(line))
88            .collect();
89        let total = visible.len();
90        let (start, end) = visible_range(scroll, view_height, line_height, overscan, total);
91        let top_pad = start as f64 * line_height;
92        let bottom_pad = total.saturating_sub(end) as f64 * line_height;
93
94        let rows = visible[start..end]
95            .iter()
96            .map(|&line_index| {
97                let text = lines[line_index].clone();
98                let is_header = headers.contains_key(&line_index);
99                let is_folded = folded.contains(&line_index);
100                let spans = match highlighter {
101                    Some(highlight) => highlight(&text),
102                    None => vec![("tok-plain", text.clone())],
103                };
104                let toggle = move |_: web_sys::MouseEvent| {
105                    folds.update(|set| {
106                        if !set.remove(&line_index) {
107                            set.insert(line_index);
108                        }
109                    });
110                };
111                view! {
112                    <div class="nightshade-surface-row" style=format!("height:{line_height}px")>
113                        <span class="nightshade-surface-num">{line_index + 1}</span>
114                        {if is_header {
115                            view! {
116                                <span
117                                    class="nightshade-surface-fold"
118                                    class:folded=is_folded
119                                    on:click=toggle
120                                >
121                                    "\u{25be}"
122                                </span>
123                            }
124                                .into_any()
125                        } else {
126                            view! { <span class="nightshade-surface-fold-spacer"></span> }.into_any()
127                        }}
128                        <span class="nightshade-surface-text">
129                            {spans
130                                .into_iter()
131                                .map(|(class, text)| view! { <span class=class>{text}</span> })
132                                .collect_view()}
133                            {is_folded.then(|| view! { <span class="nightshade-surface-ellipsis">"\u{2026}"</span> })}
134                        </span>
135                    </div>
136                }
137            })
138            .collect_view();
139
140        view! {
141            <div style=format!("height:{top_pad}px")></div>
142            {rows}
143            <div style=format!("height:{bottom_pad}px")></div>
144        }
145        })
146    };
147
148    view! {
149        <div
150            class="nightshade-code-surface-view"
151            node_ref=wrap_ref
152            style=format!("height:{height}px")
153            on:scroll=on_scroll
154        >
155            {body}
156        </div>
157    }
158}