Skip to main content

sveltui_code/
code_editor.rs

1use crate::buffer::{with_code_buffer, with_code_buffer_mut};
2use crate::highlight::highlight_text;
3use crossterm::event::KeyEvent;
4use crossterm::style::Color;
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::rc::Rc;
8use sveltui::element::scroll::{get_scroll, get_viewport, set_scroll};
9use sveltui::layout::Dimension;
10use sveltui::prelude::*;
11
12thread_local! {
13    static LAST_SYNCED: RefCell<HashMap<FocusId, String>> = RefCell::new(HashMap::new());
14}
15
16#[derive(Props)]
17pub struct CodeEditorProps {
18    #[prop(optional)]
19    pub id: Option<FocusId>,
20    /// The code. Pass it to control the editor yourself: edits then only
21    /// call `on_value_change`, and the code resets unless you pass the new
22    /// value back.
23    /// Code shared with the parent, updated on every edit. Pass it as
24    /// `bind:value={state}`.
25    #[prop(bindable)]
26    pub value: Bindable<String>,
27    /// The code it starts with when nothing else decides.
28    #[prop(optional)]
29    pub default_value: Option<String>,
30    #[prop(optional)]
31    pub on_value_change: Option<Rc<dyn Fn(String)>>,
32    #[prop(default = String::from("// Type code here..."))]
33    pub placeholder: String,
34    #[prop(optional)]
35    pub language: Option<String>,
36    #[prop(optional)]
37    pub theme: Option<String>,
38    #[prop(default = true)]
39    pub show_line_numbers: bool,
40    #[prop(default = Dimension::Auto)]
41    pub width: Dimension,
42    #[prop(default = Dimension::Auto)]
43    pub height: Dimension,
44    #[prop(optional)]
45    pub title: Option<String>,
46    #[prop(default = 4)]
47    pub tab_size: usize,
48    #[prop(default = true)]
49    pub autofocus: bool,
50    #[prop(default = CursorStyle::Line)]
51    pub cursor_style: CursorStyle,
52}
53
54#[component]
55pub fn CodeEditor(props: CodeEditorProps) -> impl IntoNode {
56    let id = props.id.unwrap_or_default();
57    let focused = is_focused(id);
58
59    let border_style = if focused {
60        BorderStyle::Double
61    } else {
62        BorderStyle::Single
63    };
64
65    let border_color = if focused {
66        Color::White
67    } else {
68        Color::DarkGrey
69    };
70
71    let bound = props.value.bound();
72    let on_value_change = props.on_value_change.clone();
73    // The parent's code, if it owns it. Without an owner, the buffer keeps
74    // the code itself after starting from `default_value`.
75    let owned = bound
76        .map(|state| state.get())
77        .or_else(|| props.value.value());
78
79    let total_lines = with_code_buffer_mut(id, |buf| {
80        buf.tab_size = props.tab_size;
81        let last = LAST_SYNCED.with(|m| m.borrow().get(&id).cloned());
82        // Sync whenever the owner's code differs from what we last synced.
83        // That covers outside changes and edits a controlled parent turned
84        // down.
85        let first = last.is_none();
86        let incoming = match (owned.clone(), last) {
87            (Some(current), Some(prev)) if prev != current => Some(current),
88            (Some(current), None) => Some(current),
89            (None, None) => Some(props.default_value.clone().unwrap_or_default()),
90            _ => None,
91        };
92        if let Some(code) = incoming {
93            // On the first render an empty value leaves the fresh buffer alone.
94            if !(first && code.is_empty()) {
95                buf.set_content(&code);
96            }
97            LAST_SYNCED.with(|m| m.borrow_mut().insert(id, code));
98        }
99        buf.line_count()
100    });
101
102    let digits = format!("{}", total_lines.max(1)).len().max(2);
103    let gutter_width = if props.show_line_numbers {
104        digits + 3
105    } else {
106        0
107    };
108
109    let (_rendered_vw, rendered_vh) = get_viewport(id).unwrap_or_else(|| {
110        let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
111        let default_h = match props.height {
112            Dimension::Length(h) => (h.round() as u16).saturating_sub(2),
113            _ => term_h.saturating_sub(2),
114        };
115        let default_w = match props.width {
116            Dimension::Length(w) => (w.round() as u16).saturating_sub(4 + gutter_width as u16),
117            _ => term_w.saturating_sub(4 + gutter_width as u16),
118        };
119        (default_w.max(1), default_h.max(1))
120    });
121
122    let visible_height = (rendered_vh as usize).max(1);
123
124    let (is_empty, full_content) = with_code_buffer(id, |buf| (buf.is_empty(), buf.content()));
125
126    let all_line_spans = highlight_text(
127        &full_content,
128        props.language.as_deref(),
129        props.theme.as_deref(),
130    );
131
132    let show_line_numbers = props.show_line_numbers;
133
134    let rendered_line_nodes: Vec<VNode> = if is_empty {
135        let mut row = ViewProps::builder()
136            .flex_direction(FlexDirection::Row)
137            .align_items(AlignItems::Center)
138            .justify_content(JustifyContent::FlexStart);
139        if show_line_numbers {
140            row = row.child(TextSpan(
141                TextSpanProps::builder()
142                    .foreground_color(Color::DarkGrey)
143                    .content(format!("{:>width$} │ ", 1, width = digits))
144                    .build(),
145            ));
146        }
147        row = row.child(TextSpan(
148            TextSpanProps::builder()
149                .foreground_color(Color::DarkGrey)
150                .content(props.placeholder.clone())
151                .build(),
152        ));
153        vec![View(row.build()).into_node()]
154    } else {
155        all_line_spans
156            .into_iter()
157            .enumerate()
158            .map(|(offset, spans)| {
159                let line_num = offset + 1;
160                let mut row = ViewProps::builder()
161                    .flex_direction(FlexDirection::Row)
162                    .align_items(AlignItems::Center)
163                    .justify_content(JustifyContent::FlexStart);
164                if show_line_numbers {
165                    row = row.child(TextSpan(
166                        TextSpanProps::builder()
167                            .foreground_color(Color::DarkGrey)
168                            .content(format!("{:>width$} │ ", line_num, width = digits))
169                            .build(),
170                    ));
171                }
172                if spans.is_empty() {
173                    row = row.child(TextSpan(
174                        TextSpanProps::builder()
175                            .foreground_color(Color::DarkGrey)
176                            .content(" ")
177                            .build(),
178                    ));
179                } else {
180                    for span in spans {
181                        row = row.child(TextSpan(
182                            TextSpanProps::builder()
183                                .foreground_color(span.fg)
184                                .bold(span.bold)
185                                .italic(span.italic)
186                                .underline(span.underline)
187                                .content(span.text)
188                                .build(),
189                        ));
190                    }
191                }
192                View(row.build()).into_node()
193            })
194            .collect()
195    };
196
197    let on_click_at: Rc<dyn Fn(u16, u16)> = Rc::new(move |rel_x: u16, rel_y: u16| {
198        let (scroll_x, scroll_y) = get_scroll(id);
199        let click_col =
200            (rel_x as i32 + scroll_x as i32 - (2 + gutter_width as i32)).max(0) as usize;
201        let click_row = (rel_y as i32 + scroll_y as i32 - 1).max(0) as usize;
202        with_code_buffer_mut(id, |buf| {
203            buf.set_cursor_pos(click_row, click_col);
204        });
205    });
206
207    let on_key: KeyHandler = Rc::new(move |key: KeyEvent, _ctx: &mut EventContext| {
208        if !with_code_buffer(id, |buf| buf.accepts_key(&key)) {
209            return EventResult::Ignored;
210        }
211
212        let (vw, vh) = get_viewport(id).unwrap_or_else(|| {
213            let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
214            (
215                term_w.saturating_sub(4 + gutter_width as u16).max(1),
216                term_h.saturating_sub(2).max(1),
217            )
218        });
219        let cur_visible_w = ((vw as usize).saturating_sub(gutter_width)).max(1);
220        let cur_visible_h = (vh as usize).max(1);
221
222        let edited = with_code_buffer_mut(id, |buf| {
223            let modified = buf.handle_key(&key);
224            let (mut sx, mut sy) = get_scroll(id);
225
226            if (buf.cursor_row as u16) < sy {
227                sy = buf.cursor_row as u16;
228            } else if buf.cursor_row >= (sy as usize) + cur_visible_h {
229                sy = (buf.cursor_row.saturating_sub(cur_visible_h) + 1) as u16;
230            }
231
232            if (buf.cursor_col as u16) < sx {
233                sx = buf.cursor_col as u16;
234            } else if buf.cursor_col >= (sx as usize) + cur_visible_w {
235                sx = (buf.cursor_col.saturating_sub(cur_visible_w) + 1) as u16;
236            }
237
238            set_scroll(id, (sx, sy));
239
240            if modified {
241                let text = buf.content();
242                LAST_SYNCED.with(|m| m.borrow_mut().insert(id, text.clone()));
243                Some(text)
244            } else {
245                None
246            }
247        });
248        // Report after the buffer is released, so callbacks can read it.
249        if let Some(text) = edited {
250            if let Some(state) = bound {
251                state.set(text.clone());
252            }
253            if let Some(callback) = &on_value_change {
254                callback(text);
255            }
256        }
257        EventResult::Handled
258    });
259
260    let (scroll_x, scroll_y) = get_scroll(id);
261    let (cur_row, cur_col) = with_code_buffer(id, |buf| (buf.cursor_row, buf.cursor_col));
262    let rel_row = (cur_row as i32) - (scroll_y as i32);
263    let rel_col = (gutter_width as i32 + cur_col as i32) - (scroll_x as i32);
264
265    let cursor_offset =
266        if focused && rel_row >= 0 && rel_col >= 0 && (rel_row as usize) < visible_height {
267            Some((rel_col as u16, rel_row as u16))
268        } else {
269            None
270        };
271
272    let cursor_style = if focused {
273        Some(props.cursor_style)
274    } else {
275        None
276    };
277
278    let title_str = props
279        .title
280        .clone()
281        .unwrap_or_else(|| props.placeholder.clone());
282
283    let focus_scope = if props.autofocus {
284        Some(FocusScope {
285            contain_tab: false,
286            initial_focus: InitialFocus::Container,
287        })
288    } else {
289        None
290    };
291
292    view! {
293        <Pressable
294            focus_id=id
295            tab_stop=true
296            focus_scope=focus_scope
297            semantics=Semantics::new(Role::Textbox, title_str)
298            on_click_at=on_click_at
299            on_key=on_key
300        >
301            <View
302                border=border_style
303                border_color=border_color
304                padding=Padding::symmetric(1, 0)
305                width=props.width
306                height=props.height
307                flex_direction=FlexDirection::Column
308                align_items=AlignItems::FlexStart
309                justify_content=JustifyContent::FlexStart
310                overflow_x=Overflow::Scroll
311                overflow_y=Overflow::Scroll
312                scroll_id=id
313                title=props.title
314                cursor_offset=cursor_offset
315                cursor_style=cursor_style
316            >
317                {#each rendered_line_nodes as line_node}
318                    {line_node}
319                {/each}
320            </View>
321        </Pressable>
322    }
323}