Skip to main content

nightshade_api/web/
multi_editor.rs

1//! A multi-cursor code editor rendered on a virtualized monospace grid.
2
3use leptos::html;
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6use wasm_bindgen::prelude::Closure;
7
8use crate::web::code_editor::Highlighter;
9use crate::web::{use_retained_closures, visible_range};
10
11fn selection_text(chars: &[char], carets: &[Caret]) -> String {
12    let mut sorted = carets.to_vec();
13    sorted.sort_by_key(Caret::low);
14    sorted
15        .iter()
16        .map(|caret| chars[caret.low()..caret.high()].iter().collect::<String>())
17        .collect::<Vec<_>>()
18        .join("\n")
19}
20
21fn move_line_edge(chars: &[char], carets: &mut [Caret], to_end: bool, extend: bool) {
22    for caret in carets.iter_mut() {
23        let (line, _) = line_col(chars, caret.head);
24        let target = if to_end {
25            line_start(chars, line) + line_len(chars, line)
26        } else {
27            line_start(chars, line)
28        };
29        caret.head = target;
30        if !extend {
31            caret.anchor = target;
32        }
33    }
34}
35
36fn write_clipboard(text: &str) {
37    if let Some(window) = web_sys::window() {
38        let _ = window.navigator().clipboard().write_text(text);
39    }
40}
41
42#[derive(Clone, Copy)]
43struct Caret {
44    anchor: usize,
45    head: usize,
46}
47
48impl Caret {
49    fn collapsed(position: usize) -> Self {
50        Self {
51            anchor: position,
52            head: position,
53        }
54    }
55
56    fn low(&self) -> usize {
57        self.anchor.min(self.head)
58    }
59
60    fn high(&self) -> usize {
61        self.anchor.max(self.head)
62    }
63}
64
65fn line_col(chars: &[char], index: usize) -> (usize, usize) {
66    let mut line = 0;
67    let mut col = 0;
68    for &character in &chars[..index.min(chars.len())] {
69        if character == '\n' {
70            line += 1;
71            col = 0;
72        } else {
73            col += 1;
74        }
75    }
76    (line, col)
77}
78
79fn line_start(chars: &[char], line: usize) -> usize {
80    let mut current = 0;
81    let mut index = 0;
82    while index < chars.len() && current < line {
83        if chars[index] == '\n' {
84            current += 1;
85        }
86        index += 1;
87    }
88    index
89}
90
91fn line_len(chars: &[char], line: usize) -> usize {
92    let start = line_start(chars, line);
93    let mut end = start;
94    while end < chars.len() && chars[end] != '\n' {
95        end += 1;
96    }
97    end - start
98}
99
100fn total_lines(chars: &[char]) -> usize {
101    chars.iter().filter(|&&character| character == '\n').count() + 1
102}
103
104fn offset_of(chars: &[char], line: usize, col: usize) -> usize {
105    line_start(chars, line) + col.min(line_len(chars, line))
106}
107
108fn normalize(carets: &mut Vec<Caret>) {
109    carets.sort_by_key(Caret::low);
110    carets.dedup_by_key(|caret| (caret.anchor, caret.head));
111}
112
113fn insert_text(chars: &mut Vec<char>, carets: &mut [Caret], insert: &[char]) {
114    carets.sort_by_key(Caret::low);
115    let mut delta: isize = 0;
116    for caret in carets.iter_mut() {
117        let low = (caret.low() as isize + delta) as usize;
118        let high = (caret.high() as isize + delta) as usize;
119        chars.splice(low..high, insert.iter().copied());
120        *caret = Caret::collapsed(low + insert.len());
121        delta += insert.len() as isize - (high as isize - low as isize);
122    }
123}
124
125fn delete_backward(chars: &mut Vec<char>, carets: &mut [Caret]) {
126    carets.sort_by_key(Caret::low);
127    let mut delta: isize = 0;
128    for caret in carets.iter_mut() {
129        let low = (caret.low() as isize + delta) as usize;
130        let high = (caret.high() as isize + delta) as usize;
131        let (from, to) = if high > low {
132            (low, high)
133        } else if low > 0 {
134            (low - 1, low)
135        } else {
136            (low, low)
137        };
138        chars.drain(from..to);
139        *caret = Caret::collapsed(from);
140        delta -= (to - from) as isize;
141    }
142}
143
144fn move_horizontal(chars: &[char], carets: &mut [Caret], direction: isize, extend: bool) {
145    let len = chars.len() as isize;
146    for caret in carets.iter_mut() {
147        let head = (caret.head as isize + direction).clamp(0, len) as usize;
148        caret.head = head;
149        if !extend {
150            caret.anchor = head;
151        }
152    }
153}
154
155fn move_vertical(chars: &[char], carets: &mut [Caret], direction: isize, extend: bool) {
156    for caret in carets.iter_mut() {
157        let (line, col) = line_col(chars, caret.head);
158        let target = (line as isize + direction).max(0) as usize;
159        if target < total_lines(chars) {
160            caret.head = offset_of(chars, target, col);
161            if !extend {
162                caret.anchor = caret.head;
163            }
164        }
165    }
166}
167
168fn word_bounds(chars: &[char], index: usize) -> (usize, usize) {
169    let is_word = |character: char| character.is_alphanumeric() || character == '_';
170    let mut start = index;
171    while start > 0 && chars.get(start - 1).is_some_and(|&c| is_word(c)) {
172        start -= 1;
173    }
174    let mut end = index;
175    while end < chars.len() && chars.get(end).is_some_and(|&c| is_word(c)) {
176        end += 1;
177    }
178    (start, end)
179}
180
181fn add_next_occurrence(chars: &[char], carets: &mut Vec<Caret>) {
182    let Some(last) = carets.iter().max_by_key(|caret| caret.high()).copied() else {
183        return;
184    };
185    let (low, high) = if last.high() > last.low() {
186        (last.low(), last.high())
187    } else {
188        word_bounds(chars, last.head)
189    };
190    if high <= low {
191        return;
192    }
193    let needle = &chars[low..high];
194    let mut cursor = high;
195    while cursor + needle.len() <= chars.len() {
196        if &chars[cursor..cursor + needle.len()] == needle {
197            carets.push(Caret {
198                anchor: cursor,
199                head: cursor + needle.len(),
200            });
201            return;
202        }
203        cursor += 1;
204    }
205}
206
207/// A multi-cursor code editor over the `value` buffer on a virtualized
208/// monospace grid: add cursors above/below (Ctrl/Cmd+Alt+Arrow), add-next-
209/// occurrence (Ctrl/Cmd+D), select-all, drag-select, clipboard, and IME
210/// composition via a hidden textarea sink. Renders only the rows in the visible
211/// window (plus `overscan`) at the given `line_height`, `height`, and optional
212/// `highlighter`.
213#[component]
214pub fn MultiEditor(
215    value: RwSignal<String>,
216    #[prop(optional)] highlighter: Option<Highlighter>,
217    #[prop(default = 20.0)] line_height: f64,
218    #[prop(default = 360.0)] height: f64,
219    #[prop(default = 12)] overscan: usize,
220) -> impl IntoView {
221    let carets = RwSignal::new(vec![Caret::collapsed(0)]);
222    let scroll_top = RwSignal::new(0.0);
223    let viewport_height = RwSignal::new(height);
224    let wrap_ref = NodeRef::<html::Div>::new();
225    let ruler_ref = NodeRef::<html::Span>::new();
226    let pressed = StoredValue::new(false);
227    let retained = use_retained_closures();
228    let textarea_ref = NodeRef::<html::Textarea>::new();
229    let composing = StoredValue::new(false);
230
231    let model = Memo::new(move |_| {
232        let source = value.get();
233        (
234            source.chars().collect::<Vec<char>>(),
235            source
236                .split('\n')
237                .map(str::to_string)
238                .collect::<Vec<String>>(),
239        )
240    });
241
242    let offset_at = move |event: &web_sys::PointerEvent| -> Option<usize> {
243        let wrap = wrap_ref.get()?;
244        let rect = wrap.get_bounding_client_rect();
245        let char_width = ruler_ref
246            .get()
247            .map(|ruler| ruler.get_bounding_client_rect().width() / 10.0)
248            .filter(|width| *width > 0.0)
249            .unwrap_or(8.0);
250        let x = event.client_x() as f64 - rect.left() + wrap.scroll_left() as f64 - 10.0;
251        let y = event.client_y() as f64 - rect.top() + wrap.scroll_top() as f64;
252        let target_line = (y / line_height).floor().max(0.0) as usize;
253        let target_col = (x / char_width).round().max(0.0) as usize;
254        model.with(|(chars, _)| {
255            let line = target_line.min(total_lines(chars).saturating_sub(1));
256            Some(offset_of(chars, line, target_col))
257        })
258    };
259
260    let on_pointer_down = move |event: web_sys::PointerEvent| {
261        if let Some(offset) = offset_at(&event) {
262            carets.set(vec![Caret::collapsed(offset)]);
263            pressed.set_value(true);
264            if let Some(area) = textarea_ref.get() {
265                let _ = area.focus();
266            }
267        }
268    };
269    let on_pointer_move = move |event: web_sys::PointerEvent| {
270        if pressed.get_value()
271            && let Some(offset) = offset_at(&event)
272        {
273            carets.update(|list| {
274                if let Some(first) = list.first_mut() {
275                    first.head = offset;
276                }
277            });
278        }
279    };
280    let release_handle =
281        window_event_listener(leptos::ev::pointerup, move |_| pressed.set_value(false));
282    on_cleanup(move || release_handle.remove());
283
284    let edit = move |mutate: &dyn Fn(&mut Vec<char>, &mut Vec<Caret>)| {
285        let mut chars: Vec<char> = value.get_untracked().chars().collect();
286        let mut current = carets.get_untracked();
287        mutate(&mut chars, &mut current);
288        normalize(&mut current);
289        value.set(chars.into_iter().collect());
290        carets.set(current);
291    };
292
293    let copy_selection = move || {
294        let chars: Vec<char> = value.get_untracked().chars().collect();
295        let text = selection_text(&chars, &carets.get_untracked());
296        if !text.is_empty() {
297            write_clipboard(&text);
298        }
299    };
300    let paste = move || {
301        let Some(window) = web_sys::window() else {
302            return;
303        };
304        let promise = window.navigator().clipboard().read_text();
305        let closure = Closure::<dyn FnMut(wasm_bindgen::JsValue)>::new(
306            move |result: wasm_bindgen::JsValue| {
307                if let Some(text) = result.as_string() {
308                    let insert: Vec<char> = text.chars().collect();
309                    let mut chars: Vec<char> = value.get_untracked().chars().collect();
310                    let mut current = carets.get_untracked();
311                    insert_text(&mut chars, &mut current, &insert);
312                    normalize(&mut current);
313                    value.set(chars.into_iter().collect());
314                    carets.set(current);
315                }
316            },
317        );
318        let _ = promise.then(&closure);
319        retained.retain(closure);
320    };
321
322    let commit_input = move || {
323        if let Some(area) = textarea_ref.get() {
324            let text = area.value();
325            if !text.is_empty() {
326                let inserted: Vec<char> = text.chars().collect();
327                edit(&move |chars, carets| insert_text(chars, carets, &inserted));
328                area.set_value("");
329            }
330        }
331    };
332    let on_input = move |_: web_sys::Event| {
333        if !composing.get_value() {
334            commit_input();
335        }
336    };
337    let on_compose_start = move |_: web_sys::CompositionEvent| composing.set_value(true);
338    let on_compose_end = move |_: web_sys::CompositionEvent| {
339        composing.set_value(false);
340        commit_input();
341    };
342
343    let on_key = move |event: web_sys::KeyboardEvent| {
344        let key = event.key();
345        let ctrl = event.ctrl_key() || event.meta_key();
346        let alt = event.alt_key();
347        let shift = event.shift_key();
348
349        if ctrl && alt && key == "ArrowDown" {
350            event.prevent_default();
351            edit(&|chars, carets| {
352                if let Some(last) = carets.iter().max_by_key(|caret| caret.high()).copied() {
353                    let (line, col) = line_col(chars, last.head);
354                    if line + 1 < total_lines(chars) {
355                        carets.push(Caret::collapsed(offset_of(chars, line + 1, col)));
356                    }
357                }
358            });
359            return;
360        }
361        if ctrl && alt && key == "ArrowUp" {
362            event.prevent_default();
363            edit(&|chars, carets| {
364                if let Some(first) = carets.iter().min_by_key(|caret| caret.low()).copied() {
365                    let (line, col) = line_col(chars, first.head);
366                    if line > 0 {
367                        carets.push(Caret::collapsed(offset_of(chars, line - 1, col)));
368                    }
369                }
370            });
371            return;
372        }
373        if ctrl && key == "d" {
374            event.prevent_default();
375            edit(&|chars, carets| add_next_occurrence(chars, carets));
376            return;
377        }
378        if ctrl && key == "a" {
379            event.prevent_default();
380            let len = value.get_untracked().chars().count();
381            carets.set(vec![Caret {
382                anchor: 0,
383                head: len,
384            }]);
385            return;
386        }
387        if ctrl && key == "c" {
388            event.prevent_default();
389            copy_selection();
390            return;
391        }
392        if ctrl && key == "x" {
393            event.prevent_default();
394            copy_selection();
395            edit(&|chars, carets| insert_text(chars, carets, &[]));
396            return;
397        }
398        if ctrl && key == "v" {
399            event.prevent_default();
400            paste();
401            return;
402        }
403        match key.as_str() {
404            "Home" => {
405                event.prevent_default();
406                edit(&move |chars, carets| move_line_edge(chars, carets, false, shift));
407            }
408            "End" => {
409                event.prevent_default();
410                edit(&move |chars, carets| move_line_edge(chars, carets, true, shift));
411            }
412            "ArrowLeft" => {
413                event.prevent_default();
414                edit(&move |chars, carets| move_horizontal(chars, carets, -1, shift));
415            }
416            "ArrowRight" => {
417                event.prevent_default();
418                edit(&move |chars, carets| move_horizontal(chars, carets, 1, shift));
419            }
420            "ArrowUp" => {
421                event.prevent_default();
422                edit(&move |chars, carets| move_vertical(chars, carets, -1, shift));
423            }
424            "ArrowDown" => {
425                event.prevent_default();
426                edit(&move |chars, carets| move_vertical(chars, carets, 1, shift));
427            }
428            "Backspace" => {
429                event.prevent_default();
430                edit(&|chars, carets| delete_backward(chars, carets));
431            }
432            "Enter" => {
433                event.prevent_default();
434                edit(&|chars, carets| insert_text(chars, carets, &['\n']));
435            }
436            "Tab" => {
437                event.prevent_default();
438                edit(&|chars, carets| insert_text(chars, carets, &[' ', ' ', ' ', ' ']));
439            }
440            "Escape" => {
441                event.prevent_default();
442                carets.update(|list| {
443                    if let Some(first) = list.first().copied() {
444                        *list = vec![Caret::collapsed(first.head)];
445                    }
446                });
447            }
448            _ => {}
449        }
450    };
451
452    let on_scroll = move |event: web_sys::Event| {
453        if let Some(element) = event
454            .target()
455            .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
456        {
457            scroll_top.set(element.scroll_top() as f64);
458            viewport_height.set(element.client_height() as f64);
459        }
460    };
461
462    Effect::new(move |_| {
463        if let Some(element) = wrap_ref.get() {
464            viewport_height.set(element.client_height() as f64);
465        }
466    });
467
468    let content = move || {
469        let view_height = viewport_height.get().max(line_height);
470        let scroll = scroll_top.get();
471        let cursors = carets.get();
472        model.with(|(chars, lines)| {
473        let total = lines.len();
474        let (start, end) = visible_range(scroll, view_height, line_height, overscan, total);
475
476        let rendered_lines = (start..end)
477            .map(|index| {
478                let text = lines[index].clone();
479                let spans = match highlighter {
480                    Some(highlight) => highlight(&text),
481                    None => vec![("tok-plain", text)],
482                };
483                view! {
484                    <div
485                        class="nightshade-ml-line"
486                        style=format!("top:{}px;height:{}px", index as f64 * line_height, line_height)
487                    >
488                        {spans
489                            .into_iter()
490                            .map(|(class, text)| view! { <span class=class>{text}</span> })
491                            .collect_view()}
492                    </div>
493                }
494            })
495            .collect_view();
496
497        let overlays = cursors
498            .into_iter()
499            .flat_map(|caret| {
500                let mut nodes = Vec::new();
501                if caret.high() > caret.low() {
502                    let (start_line, start_col) = line_col(chars, caret.low());
503                    let (end_line, end_col) = line_col(chars, caret.high());
504                    for line in start_line..=end_line {
505                        let from = if line == start_line { start_col } else { 0 };
506                        let to = if line == end_line {
507                            end_col
508                        } else {
509                            line_len(chars, line) + 1
510                        };
511                        nodes.push(
512                            view! {
513                                <div
514                                    class="nightshade-ml-selection"
515                                    style=format!(
516                                        "top:{}px;height:{}px;left:calc(10px + {}ch);width:calc({}ch)",
517                                        line as f64 * line_height,
518                                        line_height,
519                                        from,
520                                        to.saturating_sub(from),
521                                    )
522                                ></div>
523                            }
524                            .into_any(),
525                        );
526                    }
527                }
528                let (line, col) = line_col(chars, caret.head);
529                nodes.push(
530                    view! {
531                        <div
532                            class="nightshade-ml-caret"
533                            style=format!(
534                                "top:{}px;height:{}px;left:calc(10px + {}ch)",
535                                line as f64 * line_height,
536                                line_height,
537                                col,
538                            )
539                        ></div>
540                    }
541                    .into_any(),
542                );
543                nodes
544            })
545            .collect_view();
546
547        view! {
548            <div
549                class="nightshade-ml-inner"
550                style=format!("height:{}px", total as f64 * line_height)
551            >
552                {overlays}
553                {rendered_lines}
554            </div>
555        }
556        })
557    };
558
559    view! {
560        <div
561            class="nightshade-ml"
562            tabindex="0"
563            node_ref=wrap_ref
564            style=format!("height:{height}px")
565            on:keydown=on_key
566            on:scroll=on_scroll
567            on:pointerdown=on_pointer_down
568            on:pointermove=on_pointer_move
569        >
570            <span node_ref=ruler_ref class="nightshade-ml-ruler">"0000000000"</span>
571            <textarea
572                class="nightshade-ml-sink"
573                node_ref=textarea_ref
574                on:input=on_input
575                on:compositionstart=on_compose_start
576                on:compositionend=on_compose_end
577                style=move || {
578                    let head = carets.get().last().map(|caret| caret.head).unwrap_or(0);
579                    model.with(|(chars, _)| {
580                        let (line, col) = line_col(chars, head);
581                        format!(
582                            "top:{}px; left:calc(10px + {}ch); height:{}px",
583                            line as f64 * line_height,
584                            col,
585                            line_height,
586                        )
587                    })
588                }
589            ></textarea>
590            {content}
591        </div>
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::{Caret, add_next_occurrence, delete_backward, insert_text};
598
599    fn chars(text: &str) -> Vec<char> {
600        text.chars().collect()
601    }
602
603    #[test]
604    fn insert_at_multiple_carets_shifts_correctly() {
605        let mut buffer = chars("ab\ncd");
606        let mut carets = vec![Caret::collapsed(0), Caret::collapsed(3)];
607        insert_text(&mut buffer, &mut carets, &['X']);
608        assert_eq!(buffer.iter().collect::<String>(), "Xab\nXcd");
609        assert_eq!(carets[0].head, 1);
610        assert_eq!(carets[1].head, 5);
611    }
612
613    #[test]
614    fn backspace_deletes_before_each_caret() {
615        let mut buffer = chars("ab\ncd");
616        let mut carets = vec![Caret::collapsed(2), Caret::collapsed(5)];
617        delete_backward(&mut buffer, &mut carets);
618        assert_eq!(buffer.iter().collect::<String>(), "a\nc");
619    }
620
621    #[test]
622    fn selection_text_joins_multiple_carets() {
623        let buffer = super::selection_text(
624            &chars("abcdef"),
625            &[Caret { anchor: 0, head: 2 }, Caret { anchor: 4, head: 6 }],
626        );
627        assert_eq!(buffer, "ab\nef");
628    }
629
630    #[test]
631    fn add_next_occurrence_selects_following_match() {
632        let buffer = chars("foo bar foo");
633        let mut carets = vec![Caret { anchor: 0, head: 3 }];
634        add_next_occurrence(&buffer, &mut carets);
635        assert_eq!(carets.len(), 2);
636        assert_eq!(carets[1].anchor, 8);
637        assert_eq!(carets[1].head, 11);
638    }
639}