Skip to main content

sveltui_code/
code_editor.rs

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