nightshade_api/web/
code_surface.rs1use 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#[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}