sveltui_code/
code_editor.rs1use 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 #[prop(bindable)]
26 pub value: Bindable<String>,
27 #[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 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 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 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 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}